Skip to main content

lean_ctx/core/graph_index/
mod.rs

1// DEPRECATED: This module is being replaced by PropertyGraph (core/property_graph/).
2// New code should use GraphProvider (core/graph_provider.rs) instead of accessing
3// ProjectIndex directly. The dashboard now resolves graphs through
4// `graph_coordinator` (PropertyGraph-first); the remaining direct consumers are
5// the build pipeline (index_orchestrator) and the extractor itself.
6// See OPT-14/15 (#696) plan for the full migration path.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::core::import_resolver;
14use crate::core::signatures;
15mod edges;
16pub(crate) use edges::*;
17#[cfg(test)]
18mod tests;
19
20const INDEX_VERSION: u32 = 6;
21
22// Path-key utilities moved to `core::index_paths` (#682); re-exported so existing
23// `graph_index::…` call sites keep compiling during the migration.
24use crate::core::index_paths::normalize_absolute_path;
25pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
26
27pub fn is_safe_scan_root_public(path: &str) -> bool {
28    is_safe_scan_root(path)
29}
30
31fn is_filesystem_root(path: &str) -> bool {
32    let p = Path::new(path);
33    p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
34}
35
36/// Returns `true` if `dir` contains a known project marker.
37///
38/// Delegates to the single TCC-guarded probe in `pathutil` (#356) so a
39/// launchd-standalone process never stats marker files under ~/Documents, and
40/// the marker set stays defined in exactly one place (`pathutil::PROJECT_MARKERS`).
41fn dir_has_project_marker(dir: &Path) -> bool {
42    crate::core::pathutil::has_project_marker(dir)
43}
44
45/// True if `p` or any ancestor strictly *below* `stop` contains a project
46/// marker. Subdirectories of a real project (e.g. `repo/rust/src`) are
47/// legitimate scan roots even though the marker lives at the repo root —
48/// refusing them produced WARN noise on every grep/ls inside ~/Documents
49/// projects (GL#438). `stop` itself is never checked, so a marker-less
50/// `~/Documents` stays refused.
51fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
52    let mut cur = Some(p);
53    while let Some(dir) = cur {
54        if dir == stop {
55            return false;
56        }
57        if dir_has_project_marker(dir) {
58            return true;
59        }
60        cur = dir.parent();
61    }
62    false
63}
64
65fn is_safe_scan_root(path: &str) -> bool {
66    let normalized = normalize_project_root(path);
67    let p = Path::new(&normalized);
68
69    // macOS TCC (#356): a launchd-standalone process must never stat or
70    // enumerate under ~/Documents/Desktop/Downloads. Refuse such roots before
71    // any marker probe / read_dir runs. Editor- and CLI-attached processes
72    // inherit a TCC grant and keep indexing those projects normally.
73    if !crate::core::pathutil::may_probe_path(p) {
74        return false;
75    }
76
77    if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
78        tracing::warn!("[graph_index: refusing to scan filesystem root]");
79        return false;
80    }
81
82    if normalized == "." || normalized.is_empty() {
83        tracing::warn!("[graph_index: refusing to scan relative/empty root]");
84        return false;
85    }
86
87    if let Some(home) = dirs::home_dir() {
88        let home_norm = normalize_project_root(&home.to_string_lossy());
89        if normalized == home_norm {
90            use std::sync::Once;
91            static HOME_WARN: Once = Once::new();
92            HOME_WARN.call_once(|| {
93                tracing::warn!(
94                    "[graph_index: skipping — cannot index home directory {normalized}.\n  \
95                     Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
96                );
97            });
98            return false;
99        }
100        // macOS TCC: Documents/Desktop/Downloads pop a privacy prompt the moment
101        // we stat or enumerate inside them (#356). They are never valid scan roots,
102        // so refuse here before any has_marker stat or read_dir runs.
103        if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
104            tracing::warn!(
105                "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
106            );
107            return false;
108        }
109        // Block common broad home subdirectories that are never valid project roots
110        let home_path = Path::new(&home_norm);
111        const BLOCKED_HOME_SUBDIRS: &[&str] = &[
112            "Desktop",
113            "Documents",
114            "Downloads",
115            "Pictures",
116            "Music",
117            "Videos",
118            "Movies",
119            "Library",
120            ".local",
121            ".cache",
122            ".config",
123            "snap",
124            "Applications",
125            // Cloud-sync roots: scanning these forces on-demand providers to
126            // hydrate (download) every placeholder file/folder (#363). iCloud's
127            // backing dir (~/Library/Mobile Documents) is already covered by
128            // "Library" above.
129            "OneDrive",
130            "Dropbox",
131            "Google Drive",
132        ];
133        for blocked in BLOCKED_HOME_SUBDIRS {
134            let blocked_path = home_path.join(blocked);
135            let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
136            // Markers may live in an *ancestor*: `repo/rust/src` is a legitimate
137            // scan root of the project rooted at `repo` (GL#438). Walk up to (but
138            // not past) the blocked dir itself, so `~/Documents` without any
139            // project stays refused.
140            let has_marker = has_marker_in_ancestry(p, &blocked_path);
141            if is_inside_blocked
142                && !has_marker
143                && !crate::core::pathutil::has_multi_repo_children(p)
144            {
145                tracing::warn!(
146                    "[graph_index: refusing to scan {normalized} — \
147                     inside home/{blocked} without project markers]"
148                );
149                return false;
150            }
151        }
152
153        // Block directories that are direct children of home without project markers
154        // (but allow multi-repo workspace parents like ~/code/)
155        if p.parent() == Some(home_path)
156            && !dir_has_project_marker(p)
157            && !crate::core::pathutil::has_multi_repo_children(p)
158        {
159            tracing::warn!(
160                "[graph_index: refusing to scan {normalized} — \
161                 direct child of home without project markers]"
162            );
163            return false;
164        }
165    }
166
167    let breadth_markers = [
168        ".git",
169        "Cargo.toml",
170        "package.json",
171        "go.mod",
172        "pyproject.toml",
173        "setup.py",
174        "Makefile",
175        "CMakeLists.txt",
176        "pnpm-workspace.yaml",
177        ".projectile",
178        "BUILD.bazel",
179        "go.work",
180    ];
181
182    if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
183        // Multi-repo workspace parent: >=2 children with project markers is always safe
184        if crate::core::pathutil::has_multi_repo_children(p) {
185            return true;
186        }
187
188        let child_count = std::fs::read_dir(p).map_or(0, |rd| {
189            rd.filter_map(Result::ok)
190                .filter(|e| e.path().is_dir())
191                .count()
192        });
193        if child_count > 50 {
194            tracing::warn!(
195                "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
196                 skipping scan to avoid indexing broad directories]"
197            );
198            return false;
199        }
200    }
201
202    true
203}
204
205/// True if the directory contains a .NET project/solution file (`*.csproj`,
206/// `*.sln`, `*.fsproj`, `*.vbproj`). Filenames vary, so we match by extension —
207/// these are strong project-root markers even when there is no `.git`.
208fn dir_has_dotnet_project(dir: &Path) -> bool {
209    std::fs::read_dir(dir).is_ok_and(|rd| {
210        rd.filter_map(Result::ok).any(|e| {
211            e.path()
212                .extension()
213                .and_then(|x| x.to_str())
214                .is_some_and(|x| {
215                    matches!(
216                        x.to_ascii_lowercase().as_str(),
217                        "csproj" | "sln" | "fsproj" | "vbproj"
218                    )
219                })
220        })
221    })
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ProjectIndex {
226    pub version: u32,
227    pub project_root: String,
228    pub last_scan: String,
229    pub files: HashMap<String, FileEntry>,
230    pub edges: Vec<IndexEdge>,
231    pub symbols: HashMap<String, SymbolEntry>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct FileEntry {
236    pub path: String,
237    pub hash: String,
238    pub language: String,
239    pub line_count: usize,
240    pub token_count: usize,
241    pub exports: Vec<String>,
242    pub summary: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct SymbolEntry {
247    pub file: String,
248    pub name: String,
249    pub kind: String,
250    pub start_line: usize,
251    pub end_line: usize,
252    pub is_exported: bool,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct IndexEdge {
257    pub from: String,
258    pub to: String,
259    pub kind: String,
260    #[serde(default = "default_edge_weight")]
261    pub weight: f32,
262}
263
264fn default_edge_weight() -> f32 {
265    1.0
266}
267
268impl ProjectIndex {
269    pub fn new(project_root: &str) -> Self {
270        Self {
271            version: INDEX_VERSION,
272            project_root: normalize_project_root(project_root),
273            last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
274            files: HashMap::new(),
275            edges: Vec::new(),
276            symbols: HashMap::new(),
277        }
278    }
279
280    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
281        let normalized = normalize_project_root(project_root);
282        let hash = crate::core::project_hash::hash_project_root(&normalized);
283        crate::core::data_dir::lean_ctx_data_dir()
284            .ok()
285            .map(|d| d.join("graphs").join(hash))
286    }
287
288    /// Reconstruct the index from the property graph — the sole persistence
289    /// store since #696 C4. The PG is a parity-proven lossless superset of the
290    /// former JSON index, so this is a faithful round-trip (see
291    /// `graph_provider::materialize_project_index` + its round-trip test).
292    /// `None` when the graph has not been built yet (empty file catalog).
293    pub fn load(project_root: &str) -> Option<Self> {
294        let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
295        if graph.file_catalog_count().unwrap_or(0) == 0 {
296            return None;
297        }
298        let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
299        Some(provider.materialize_project_index(project_root))
300    }
301
302    /// Persist the index by mirroring it into the property graph (the sole store
303    /// since #696 C4). Replaces the former `index.json.zst` write; the mirror
304    /// also stamps `graph.meta.json`, which the resident graph cache fingerprints
305    /// for invalidation.
306    pub fn save(&self) -> Result<(), String> {
307        crate::core::property_graph::mirror_index(&self.project_root, self)
308            .map_err(|e| e.to_string())
309    }
310
311    /// Remove all cached graph indices that are older than max_age_hours.
312    /// Called on startup/update to prevent stale data from persisting.
313    pub fn purge_stale_indices() {
314        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
315            return;
316        };
317        let graphs_dir = data_dir.join("graphs");
318        let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
319            return;
320        };
321        let cfg = crate::core::config::Config::load();
322        let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
323
324        for entry in entries.filter_map(Result::ok) {
325            let path = entry.path();
326            if !path.is_dir() {
327                continue;
328            }
329            // #696 C4: the property graph (graph.db, stamped by graph.meta.json)
330            // is the sole store; age the directory by its last build instead of
331            // the retired JSON index.
332            let meta = path.join("graph.meta.json");
333            let db = path.join("graph.db");
334            let index_file = if meta.exists() {
335                &meta
336            } else if db.exists() {
337                &db
338            } else {
339                continue;
340            };
341
342            let is_old = index_file
343                .metadata()
344                .and_then(|m| m.modified())
345                .is_ok_and(|mtime| {
346                    mtime
347                        .elapsed()
348                        .is_ok_and(|age| age.as_secs() > max_age_secs)
349                });
350
351            if is_old {
352                tracing::info!("[graph_index: purging stale index at {}]", path.display());
353                let _ = std::fs::remove_dir_all(&path);
354            }
355        }
356    }
357
358    pub fn file_count(&self) -> usize {
359        self.files.len()
360    }
361
362    pub fn symbol_count(&self) -> usize {
363        self.symbols.len()
364    }
365
366    pub fn edge_count(&self) -> usize {
367        self.edges.len()
368    }
369
370    pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
371        self.symbols.get(key)
372    }
373
374    pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
375        let mut result = Vec::new();
376        let mut visited = std::collections::HashSet::new();
377        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
378
379        while let Some((current, d)) = queue.pop() {
380            if d > depth || visited.contains(&current) {
381                continue;
382            }
383            visited.insert(current.clone());
384            if current != path {
385                result.push(current.clone());
386            }
387
388            for edge in &self.edges {
389                if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
390                    queue.push((edge.from.clone(), d + 1));
391                }
392            }
393        }
394        result
395    }
396
397    /// Forward import dependencies: files that `path` (transitively) imports.
398    /// Mirror of `get_reverse_deps` with the edge direction flipped.
399    pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
400        let mut result = Vec::new();
401        let mut visited = std::collections::HashSet::new();
402        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
403
404        while let Some((current, d)) = queue.pop() {
405            if d > depth || visited.contains(&current) {
406                continue;
407            }
408            visited.insert(current.clone());
409            if current != path {
410                result.push(current.clone());
411            }
412
413            for edge in &self.edges {
414                if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
415                    queue.push((edge.to.clone(), d + 1));
416                }
417            }
418        }
419        result
420    }
421
422    pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
423        let mut result = Vec::new();
424        let mut visited = std::collections::HashSet::new();
425        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
426
427        while let Some((current, d)) = queue.pop() {
428            if d > depth || visited.contains(&current) {
429                continue;
430            }
431            visited.insert(current.clone());
432            if current != path {
433                result.push(current.clone());
434            }
435
436            for edge in &self.edges {
437                if edge.from == current && !visited.contains(&edge.to) {
438                    queue.push((edge.to.clone(), d + 1));
439                }
440                if edge.to == current && !visited.contains(&edge.from) {
441                    queue.push((edge.from.clone(), d + 1));
442                }
443            }
444        }
445        result
446    }
447}
448
449/// Load the best available graph index, trying multiple root path variants.
450/// If no valid index exists, automatically scans the project to build one.
451/// This is the primary entry point — ensures zero-config usage.
452pub fn load_or_build(project_root: &str) -> ProjectIndex {
453    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
454        return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
455    }
456
457    // Prefer stable absolute roots. Using "." as a cache key is fragile because
458    // it depends on the process cwd and can accidentally load the wrong project.
459    let root_abs = if project_root.trim().is_empty() || project_root == "." {
460        std::env::current_dir().ok().map_or_else(
461            || ".".to_string(),
462            |p| normalize_project_root(&p.to_string_lossy()),
463        )
464    } else {
465        normalize_project_root(project_root)
466    };
467
468    if !is_safe_scan_root(&root_abs) {
469        return ProjectIndex::new(&root_abs);
470    }
471
472    // Try the absolute/root-normalized path first.
473    if let Some(idx) = ProjectIndex::load(&root_abs)
474        && !idx.files.is_empty()
475    {
476        if index_looks_stale(&idx, &root_abs) {
477            tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
478            return scan(&root_abs);
479        }
480        return idx;
481    }
482
483    // CWD fallback: only use if CWD is a subdirectory of root_abs (same project)
484    if let Ok(cwd) = std::env::current_dir() {
485        let cwd_str = normalize_project_root(&cwd.to_string_lossy());
486        if cwd_str != root_abs
487            && cwd_str.starts_with(&root_abs)
488            && let Some(idx) = ProjectIndex::load(&cwd_str)
489            && !idx.files.is_empty()
490        {
491            if index_looks_stale(&idx, &cwd_str) {
492                return scan(&cwd_str);
493            }
494            return idx;
495        }
496    }
497
498    scan(&root_abs)
499}
500
501fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
502    if index.files.is_empty() {
503        return true;
504    }
505
506    // TTL check: rebuild if index is older than configured max_age_hours
507    if let Ok(scan_time) =
508        chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
509    {
510        let cfg = crate::core::config::Config::load();
511        let effective_hours = cfg.archive_max_age_hours_effective();
512        let max_age = chrono::Duration::hours(effective_hours as i64);
513        let now = chrono::Local::now().naive_local();
514        if now.signed_duration_since(scan_time) > max_age {
515            tracing::info!(
516                "[graph_index: index is older than {}h — marking stale]",
517                effective_hours
518            );
519            return true;
520        }
521    }
522
523    // Contamination check: if index contains paths from common user directories,
524    // it was built from a too-broad root and must be rebuilt
525    const CONTAMINATION_MARKERS: &[&str] = &[
526        "Desktop/",
527        "Documents/",
528        "Downloads/",
529        "Pictures/",
530        "Music/",
531        "Videos/",
532        "Movies/",
533        "Library/",
534        ".cache/",
535        "snap/",
536    ];
537    let contaminated = index.files.keys().take(200).any(|rel| {
538        CONTAMINATION_MARKERS
539            .iter()
540            .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
541    });
542    if contaminated {
543        tracing::warn!(
544            "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
545             marking stale to force clean rebuild]"
546        );
547        return true;
548    }
549
550    let root_path = Path::new(root_abs);
551    // Sample up to 20 files for existence check (avoid scanning all files in large indices)
552    let sample_size = index.files.len().min(20);
553    for rel in index.files.keys().take(sample_size) {
554        let rel = rel.trim_start_matches(['/', '\\']);
555        if rel.is_empty() {
556            continue;
557        }
558        let abs = root_path.join(rel);
559        if !abs.exists() {
560            return true;
561        }
562    }
563
564    // Content-aware staleness: rescan only when source *content* actually
565    // changed. mtime is a cheap prefilter; the change is then confirmed against
566    // the stored content hash so a `touch`/checkout/format that leaves bytes
567    // unchanged never forces a needless rescan (covers edits and new files).
568    if source_content_changed_since_index(index, root_abs) {
569        tracing::info!("[graph_index: source content changed since last scan — marking stale]");
570        return true;
571    }
572
573    false
574}
575
576/// Modified time of the persisted index artifact, if one exists.
577///
578/// Since #696 C4 the property graph is the sole store, so staleness is measured
579/// against `graph.meta.json` (rewritten on every mirror) with the `graph.db`
580/// file as a fallback.
581fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
582    let dir = ProjectIndex::index_dir(root_abs)?;
583    for name in ["graph.meta.json", "graph.db"] {
584        if let Ok(meta) = std::fs::metadata(dir.join(name))
585            && let Ok(modified) = meta.modified()
586        {
587            return Some(modified);
588        }
589    }
590    None
591}
592
593/// Bounded staleness check that confirms *content* changes, not just mtimes.
594///
595/// An mtime newer than the persisted index only flags a *candidate*; the change
596/// is then confirmed by comparing the file's content hash against the stored
597/// `FileEntry.hash` (same `compute_hash` + `read_to_string` the scan uses, so
598/// the comparison is exact). This means a `touch`, `git checkout`, or formatter
599/// rewrite that leaves bytes unchanged no longer forces a needless rescan, while
600/// genuine edits and newly added files still mark the index stale.
601///
602/// Both the traversal and the number of confirming reads are capped: exceeding
603/// the read cap returns `true` (conservatively stale) instead of reading an
604/// unbounded amount. Removed files are handled by the earlier existence check.
605fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
606    let Some(index_mtime) = index_file_mtime(root_abs) else {
607        // No persisted index yet — the existence/TTL checks above already decided.
608        return false;
609    };
610    let walker = ignore::WalkBuilder::new(root_abs)
611        .hidden(true)
612        .git_ignore(true)
613        .git_global(true)
614        .git_exclude(true)
615        .require_git(false)
616        .max_depth(Some(20))
617        .filter_entry(crate::core::walk_filter::keep_entry)
618        .build();
619    const MAX_VISIT: usize = 50_000;
620    const MAX_CONFIRM_READS: usize = 4_000;
621    let mut visited = 0usize;
622    let mut confirm_reads = 0usize;
623    for entry in walker.filter_map(std::result::Result::ok) {
624        visited += 1;
625        if visited > MAX_VISIT {
626            break;
627        }
628        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
629            continue;
630        }
631        let path = entry.path();
632        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
633        if !is_indexable_ext(ext) {
634            continue;
635        }
636        // mtime prefilter: only files touched after the index are candidates.
637        let Ok(meta) = entry.metadata() else { continue };
638        let Ok(modified) = meta.modified() else {
639            continue;
640        };
641        if modified <= index_mtime {
642            continue;
643        }
644        // Candidate: confirm against the stored content hash.
645        let rel = make_relative(&path.to_string_lossy(), root_abs);
646        let Some(file_entry) = index.files.get(&rel) else {
647            // A newly added indexable file is genuinely new content.
648            return true;
649        };
650        confirm_reads += 1;
651        if confirm_reads > MAX_CONFIRM_READS {
652            // Too many candidates to verify cheaply — assume stale.
653            return true;
654        }
655        match std::fs::read_to_string(path) {
656            // Bytes unchanged despite a newer mtime → not a real change.
657            Ok(content) if compute_hash(&content) == file_entry.hash => {}
658            // Edited content, or no longer readable as it was at scan time.
659            _ => return true,
660        }
661    }
662    false
663}
664
665/// Delete the persisted graph-index artifacts for a project so the next scan
666/// rebuilds from scratch. Backs `graph build --force`.
667///
668/// Since #696 C4 the property graph (`graph.db` + `graph.meta.json`) is the sole
669/// store; the legacy JSON names are still removed so upgrades clear stale files.
670pub fn purge_index(project_root: &str) {
671    if let Some(dir) = ProjectIndex::index_dir(project_root) {
672        for name in [
673            "graph.db",
674            "graph.db-wal",
675            "graph.db-shm",
676            "graph.meta.json",
677            "index.json.zst",
678            "index.json",
679            "call_graph.json.zst",
680        ] {
681            let _ = std::fs::remove_file(dir.join(name));
682        }
683    }
684}
685
686pub fn scan(project_root: &str) -> ProjectIndex {
687    scan_inner(project_root).0
688}
689
690pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
691    scan_inner(project_root)
692}
693
694fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
695    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
696        tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
697        return (ProjectIndex::new(project_root), HashMap::new());
698    }
699
700    let project_root = normalize_project_root(project_root);
701
702    if !is_safe_scan_root(&project_root) {
703        tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
704        return (ProjectIndex::new(&project_root), HashMap::new());
705    }
706
707    let lock_name = format!(
708        "graph-idx-{}",
709        &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
710    );
711    let _lock = crate::core::startup_guard::try_acquire_lock(
712        &lock_name,
713        std::time::Duration::from_millis(800),
714        std::time::Duration::from_mins(3),
715    );
716    if _lock.is_none() {
717        tracing::info!(
718            "[graph_index: another process is scanning {project_root} — returning cached or empty]"
719        );
720        return (
721            ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
722            HashMap::new(),
723        );
724    }
725
726    let existing = ProjectIndex::load(&project_root);
727    let mut index = ProjectIndex::new(&project_root);
728
729    let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
730        if let Some(ref prev) = existing {
731            prev.files
732                .iter()
733                .map(|(path, entry)| {
734                    let syms: Vec<(String, SymbolEntry)> = prev
735                        .symbols
736                        .iter()
737                        .filter(|(_, s)| s.file == *path)
738                        .map(|(k, v)| (k.clone(), v.clone()))
739                        .collect();
740                    (path.clone(), (entry.hash.clone(), syms))
741                })
742                .collect()
743        } else {
744            HashMap::new()
745        };
746
747    let walker = ignore::WalkBuilder::new(&project_root)
748        .hidden(true)
749        .git_ignore(true)
750        .git_global(true)
751        .git_exclude(true)
752        .require_git(false)
753        .max_depth(Some(20))
754        .filter_entry(crate::core::walk_filter::keep_entry)
755        .build();
756
757    let cfg = crate::core::config::Config::load();
758    let extra_ignores: Vec<glob::Pattern> = cfg
759        .extra_ignore_patterns
760        .iter()
761        .filter_map(|p| glob::Pattern::new(p).ok())
762        .collect();
763
764    let mut scanned = 0usize;
765    let mut reused = 0usize;
766    let mut entries_visited = 0usize;
767    let mut content_cache: HashMap<String, String> = HashMap::new();
768    let max_files = if cfg.graph_index_max_files == 0 {
769        usize::MAX // unlimited
770    } else {
771        cfg.graph_index_max_files as usize
772    };
773    const MAX_ENTRIES_VISITED: usize = 500_000;
774    const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; // 2 MB per file
775    let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
776
777    for entry in walker.filter_map(std::result::Result::ok) {
778        entries_visited += 1;
779        if entries_visited > MAX_ENTRIES_VISITED {
780            tracing::warn!(
781                "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
782                 runaway traversal. Indexed {} files so far.]",
783                index.files.len()
784            );
785            break;
786        }
787        if entries_visited.is_multiple_of(5000) {
788            if std::time::Instant::now() > scan_deadline {
789                tracing::warn!(
790                    "[graph_index: scan timeout (120s) after {entries_visited} entries — \
791                     saving partial index with {} files]",
792                    index.files.len()
793                );
794                break;
795            }
796            if crate::core::memory_guard::abort_requested() {
797                tracing::warn!(
798                    "[graph_index: memory pressure abort after {entries_visited} entries — \
799                     saving partial index with {} files]",
800                    index.files.len()
801                );
802                break;
803            }
804            if crate::core::memory_guard::is_under_pressure() {
805                tracing::warn!(
806                    "[graph_index: memory pressure detected at {entries_visited} entries — \
807                     stopping scan with {} files]",
808                    index.files.len()
809                );
810                break;
811            }
812            if let Some(ref g) = _lock {
813                g.touch();
814            }
815        }
816
817        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
818            continue;
819        }
820
821        if entry.path_is_symlink() {
822            continue;
823        }
824        let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
825
826        if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
827            continue;
828        }
829
830        if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
831            if meta.file_type().is_symlink() || !meta.is_file() {
832                continue;
833            }
834            if meta.len() > MAX_FILE_SIZE_BYTES {
835                tracing::debug!(
836                    "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
837                    meta.len() as f64 / 1_048_576.0,
838                    MAX_FILE_SIZE_BYTES / (1024 * 1024),
839                );
840                continue;
841            }
842        }
843
844        let ext = Path::new(&file_path)
845            .extension()
846            .and_then(|e| e.to_str())
847            .unwrap_or("");
848
849        if !is_indexable_ext(ext) {
850            continue;
851        }
852
853        let rel = make_relative(&file_path, &project_root);
854        if extra_ignores.iter().any(|p| p.matches(&rel)) {
855            continue;
856        }
857
858        if max_files != usize::MAX && index.files.len() >= max_files {
859            tracing::info!(
860                "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
861                max_files
862            );
863            break;
864        }
865
866        let Ok(content) = std::fs::read_to_string(&file_path) else {
867            continue;
868        };
869
870        let hash = compute_hash(&content);
871        let rel_path = make_relative(&file_path, &project_root);
872
873        if let Some((old_hash, old_syms)) = old_files.get(&rel_path)
874            && *old_hash == hash
875            && let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path))
876        {
877            index.files.insert(rel_path.clone(), old_entry.clone());
878            for (key, sym) in old_syms {
879                index.symbols.insert(key.clone(), sym.clone());
880            }
881            content_cache.insert(rel_path, content);
882            reused += 1;
883            continue;
884        }
885
886        let sigs = signatures::extract_signatures(&content, ext);
887        let line_count = content.lines().count();
888        let token_count = crate::core::tokens::count_tokens(&content);
889        let summary = extract_summary(&content);
890
891        let exports: Vec<String> = sigs
892            .iter()
893            .filter(|s| s.is_exported)
894            .map(|s| s.name.clone())
895            .collect();
896
897        index.files.insert(
898            rel_path.clone(),
899            FileEntry {
900                path: rel_path.clone(),
901                hash,
902                language: ext.to_string(),
903                line_count,
904                token_count,
905                exports,
906                summary,
907            },
908        );
909
910        for sig in &sigs {
911            let (start, end) = sig
912                .start_line
913                .zip(sig.end_line)
914                .unwrap_or_else(|| find_symbol_range(&content, sig));
915            let key = format!("{}::{}", rel_path, sig.name);
916            index.symbols.insert(
917                key,
918                SymbolEntry {
919                    file: rel_path.clone(),
920                    name: sig.name.clone(),
921                    kind: sig.kind.to_string(),
922                    start_line: start,
923                    end_line: end,
924                    is_exported: sig.is_exported,
925                },
926            );
927        }
928
929        content_cache.insert(rel_path, content);
930        scanned += 1;
931    }
932
933    build_edges_cached(&mut index, &content_cache);
934
935    if let Err(e) = index.save() {
936        tracing::warn!("could not save graph index: {e}");
937    }
938
939    tracing::debug!(
940        "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
941        index.file_count(),
942        scanned,
943        reused,
944        index.symbol_count(),
945        index.edge_count()
946    );
947
948    (index, content_cache)
949}
950
951fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
952    let lines: Vec<&str> = content.lines().collect();
953    let mut start = 0;
954
955    for (i, line) in lines.iter().enumerate() {
956        if line.contains(&sig.name) {
957            let trimmed = line.trim();
958            let is_def = trimmed.starts_with("fn ")
959                || trimmed.starts_with("pub fn ")
960                || trimmed.starts_with("pub(crate) fn ")
961                || trimmed.starts_with("async fn ")
962                || trimmed.starts_with("pub async fn ")
963                || trimmed.starts_with("struct ")
964                || trimmed.starts_with("pub struct ")
965                || trimmed.starts_with("enum ")
966                || trimmed.starts_with("pub enum ")
967                || trimmed.starts_with("trait ")
968                || trimmed.starts_with("pub trait ")
969                || trimmed.starts_with("impl ")
970                || trimmed.starts_with("class ")
971                || trimmed.starts_with("export class ")
972                || trimmed.starts_with("export function ")
973                || trimmed.starts_with("export async function ")
974                || trimmed.starts_with("function ")
975                || trimmed.starts_with("async function ")
976                || trimmed.starts_with("def ")
977                || trimmed.starts_with("async def ")
978                || trimmed.starts_with("func ")
979                || trimmed.starts_with("interface ")
980                || trimmed.starts_with("export interface ")
981                || trimmed.starts_with("type ")
982                || trimmed.starts_with("export type ")
983                || trimmed.starts_with("const ")
984                || trimmed.starts_with("export const ")
985                || trimmed.starts_with("fun ")
986                || trimmed.starts_with("private fun ")
987                || trimmed.starts_with("public fun ")
988                || trimmed.starts_with("internal fun ")
989                || trimmed.starts_with("class ")
990                || trimmed.starts_with("data class ")
991                || trimmed.starts_with("sealed class ")
992                || trimmed.starts_with("sealed interface ")
993                || trimmed.starts_with("enum class ")
994                || trimmed.starts_with("object ")
995                || trimmed.starts_with("private object ")
996                || trimmed.starts_with("interface ")
997                || trimmed.starts_with("typealias ")
998                || trimmed.starts_with("private typealias ");
999            if is_def {
1000                start = i + 1;
1001                break;
1002            }
1003        }
1004    }
1005
1006    if start == 0 {
1007        return (1, lines.len().min(20));
1008    }
1009
1010    let base_indent = lines
1011        .get(start - 1)
1012        .map_or(0, |l| l.len() - l.trim_start().len());
1013
1014    let mut end = start;
1015    let mut brace_depth: i32 = 0;
1016    let mut found_open = false;
1017
1018    for (i, line) in lines.iter().enumerate().skip(start - 1) {
1019        for ch in line.chars() {
1020            if ch == '{' {
1021                brace_depth += 1;
1022                found_open = true;
1023            } else if ch == '}' {
1024                brace_depth -= 1;
1025            }
1026        }
1027
1028        end = i + 1;
1029
1030        if found_open && brace_depth <= 0 {
1031            break;
1032        }
1033
1034        if !found_open && i > start {
1035            let indent = line.len() - line.trim_start().len();
1036            if indent <= base_indent && !line.trim().is_empty() && i > start {
1037                end = i;
1038                break;
1039            }
1040        }
1041
1042        if end - start > 200 {
1043            break;
1044        }
1045    }
1046
1047    (start, end)
1048}
1049
1050fn extract_summary(content: &str) -> String {
1051    for line in content.lines().take(20) {
1052        let trimmed = line.trim();
1053        if trimmed.is_empty()
1054            || trimmed.starts_with("//")
1055            || trimmed.starts_with('#')
1056            || trimmed.starts_with("/*")
1057            || trimmed.starts_with('*')
1058            || trimmed.starts_with("use ")
1059            || trimmed.starts_with("import ")
1060            || trimmed.starts_with("from ")
1061            || trimmed.starts_with("require(")
1062            || trimmed.starts_with("package ")
1063        {
1064            continue;
1065        }
1066        return trimmed.chars().take(120).collect();
1067    }
1068    String::new()
1069}
1070
1071fn compute_hash(content: &str) -> String {
1072    use std::collections::hash_map::DefaultHasher;
1073    use std::hash::{Hash, Hasher};
1074
1075    let mut hasher = DefaultHasher::new();
1076    content.hash(&mut hasher);
1077    format!("{:016x}", hasher.finish())
1078}
1079
1080#[cfg(test)]
1081fn short_hash(input: &str) -> String {
1082    use std::collections::hash_map::DefaultHasher;
1083    use std::hash::{Hash, Hasher};
1084
1085    let mut hasher = DefaultHasher::new();
1086    input.hash(&mut hasher);
1087    format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1088}
1089
1090fn make_relative(path: &str, root: &str) -> String {
1091    graph_relative_key(path, root)
1092}
1093
1094fn is_indexable_ext(ext: &str) -> bool {
1095    crate::core::language_capabilities::is_indexable_ext(ext)
1096}
1097
1098#[cfg(test)]
1099fn kotlin_package_name(content: &str) -> Option<String> {
1100    content.lines().map(str::trim).find_map(|line| {
1101        line.strip_prefix("package ")
1102            .map(|rest| rest.trim().trim_end_matches(';').to_string())
1103    })
1104}