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. Remaining direct consumers: call_graph, graph_enricher,
4// ctx_callgraph, ctx_graph_diagram, ctx_routes, autonomy, dashboard/callgraph.
5// See OPT-14/15 plan for the full migration path.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::core::import_resolver;
13use crate::core::signatures;
14mod edges;
15pub(crate) use edges::*;
16#[cfg(test)]
17mod tests;
18
19const INDEX_VERSION: u32 = 6;
20
21pub fn is_safe_scan_root_public(path: &str) -> bool {
22    is_safe_scan_root(path)
23}
24
25fn is_filesystem_root(path: &str) -> bool {
26    let p = Path::new(path);
27    p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
28}
29
30/// Project markers that mark a directory as a legitimate project root.
31const PROJECT_MARKERS: &[&str] = &[
32    ".git",
33    "Cargo.toml",
34    "package.json",
35    "go.mod",
36    "pyproject.toml",
37];
38
39fn dir_has_project_marker(dir: &Path) -> bool {
40    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
41}
42
43/// True if `p` or any ancestor strictly *below* `stop` contains a project
44/// marker. Subdirectories of a real project (e.g. `repo/rust/src`) are
45/// legitimate scan roots even though the marker lives at the repo root —
46/// refusing them produced WARN noise on every grep/ls inside ~/Documents
47/// projects (GL#438). `stop` itself is never checked, so a marker-less
48/// `~/Documents` stays refused.
49fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
50    let mut cur = Some(p);
51    while let Some(dir) = cur {
52        if dir == stop {
53            return false;
54        }
55        if dir_has_project_marker(dir) {
56            return true;
57        }
58        cur = dir.parent();
59    }
60    false
61}
62
63fn is_safe_scan_root(path: &str) -> bool {
64    let normalized = normalize_project_root(path);
65    let p = Path::new(&normalized);
66
67    if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
68        tracing::warn!("[graph_index: refusing to scan filesystem root]");
69        return false;
70    }
71
72    if normalized == "." || normalized.is_empty() {
73        tracing::warn!("[graph_index: refusing to scan relative/empty root]");
74        return false;
75    }
76
77    if let Some(home) = dirs::home_dir() {
78        let home_norm = normalize_project_root(&home.to_string_lossy());
79        if normalized == home_norm {
80            use std::sync::Once;
81            static HOME_WARN: Once = Once::new();
82            HOME_WARN.call_once(|| {
83                tracing::warn!(
84                    "[graph_index: skipping — cannot index home directory {normalized}.\n  \
85                     Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
86                );
87            });
88            return false;
89        }
90        // macOS TCC: Documents/Desktop/Downloads pop a privacy prompt the moment
91        // we stat or enumerate inside them (#356). They are never valid scan roots,
92        // so refuse here before any has_marker stat or read_dir runs.
93        if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
94            tracing::warn!(
95                "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
96            );
97            return false;
98        }
99        // Block common broad home subdirectories that are never valid project roots
100        let home_path = Path::new(&home_norm);
101        const BLOCKED_HOME_SUBDIRS: &[&str] = &[
102            "Desktop",
103            "Documents",
104            "Downloads",
105            "Pictures",
106            "Music",
107            "Videos",
108            "Movies",
109            "Library",
110            ".local",
111            ".cache",
112            ".config",
113            "snap",
114            "Applications",
115            // Cloud-sync roots: scanning these forces on-demand providers to
116            // hydrate (download) every placeholder file/folder (#363). iCloud's
117            // backing dir (~/Library/Mobile Documents) is already covered by
118            // "Library" above.
119            "OneDrive",
120            "Dropbox",
121            "Google Drive",
122        ];
123        for blocked in BLOCKED_HOME_SUBDIRS {
124            let blocked_path = home_path.join(blocked);
125            let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
126            // Markers may live in an *ancestor*: `repo/rust/src` is a legitimate
127            // scan root of the project rooted at `repo` (GL#438). Walk up to (but
128            // not past) the blocked dir itself, so `~/Documents` without any
129            // project stays refused.
130            let has_marker = has_marker_in_ancestry(p, &blocked_path);
131            if is_inside_blocked
132                && !has_marker
133                && !crate::core::pathutil::has_multi_repo_children(p)
134            {
135                tracing::warn!(
136                    "[graph_index: refusing to scan {normalized} — \
137                     inside home/{blocked} without project markers]"
138                );
139                return false;
140            }
141        }
142
143        // Block directories that are direct children of home without project markers
144        // (but allow multi-repo workspace parents like ~/code/)
145        if p.parent() == Some(home_path)
146            && !dir_has_project_marker(p)
147            && !crate::core::pathutil::has_multi_repo_children(p)
148        {
149            tracing::warn!(
150                "[graph_index: refusing to scan {normalized} — \
151                 direct child of home without project markers]"
152            );
153            return false;
154        }
155    }
156
157    let breadth_markers = [
158        ".git",
159        "Cargo.toml",
160        "package.json",
161        "go.mod",
162        "pyproject.toml",
163        "setup.py",
164        "Makefile",
165        "CMakeLists.txt",
166        "pnpm-workspace.yaml",
167        ".projectile",
168        "BUILD.bazel",
169        "go.work",
170    ];
171
172    if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
173        // Multi-repo workspace parent: >=2 children with project markers is always safe
174        if crate::core::pathutil::has_multi_repo_children(p) {
175            return true;
176        }
177
178        let child_count = std::fs::read_dir(p).map_or(0, |rd| {
179            rd.filter_map(Result::ok)
180                .filter(|e| e.path().is_dir())
181                .count()
182        });
183        if child_count > 50 {
184            tracing::warn!(
185                "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
186                 skipping scan to avoid indexing broad directories]"
187            );
188            return false;
189        }
190    }
191
192    true
193}
194
195/// True if the directory contains a .NET project/solution file (`*.csproj`,
196/// `*.sln`, `*.fsproj`, `*.vbproj`). Filenames vary, so we match by extension —
197/// these are strong project-root markers even when there is no `.git`.
198fn dir_has_dotnet_project(dir: &Path) -> bool {
199    std::fs::read_dir(dir).is_ok_and(|rd| {
200        rd.filter_map(Result::ok).any(|e| {
201            e.path()
202                .extension()
203                .and_then(|x| x.to_str())
204                .is_some_and(|x| {
205                    matches!(
206                        x.to_ascii_lowercase().as_str(),
207                        "csproj" | "sln" | "fsproj" | "vbproj"
208                    )
209                })
210        })
211    })
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ProjectIndex {
216    pub version: u32,
217    pub project_root: String,
218    pub last_scan: String,
219    pub files: HashMap<String, FileEntry>,
220    pub edges: Vec<IndexEdge>,
221    pub symbols: HashMap<String, SymbolEntry>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct FileEntry {
226    pub path: String,
227    pub hash: String,
228    pub language: String,
229    pub line_count: usize,
230    pub token_count: usize,
231    pub exports: Vec<String>,
232    pub summary: String,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SymbolEntry {
237    pub file: String,
238    pub name: String,
239    pub kind: String,
240    pub start_line: usize,
241    pub end_line: usize,
242    pub is_exported: bool,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct IndexEdge {
247    pub from: String,
248    pub to: String,
249    pub kind: String,
250    #[serde(default = "default_edge_weight")]
251    pub weight: f32,
252}
253
254fn default_edge_weight() -> f32 {
255    1.0
256}
257
258impl ProjectIndex {
259    pub fn new(project_root: &str) -> Self {
260        Self {
261            version: INDEX_VERSION,
262            project_root: normalize_project_root(project_root),
263            last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
264            files: HashMap::new(),
265            edges: Vec::new(),
266            symbols: HashMap::new(),
267        }
268    }
269
270    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
271        let normalized = normalize_project_root(project_root);
272        let hash = crate::core::project_hash::hash_project_root(&normalized);
273        crate::core::data_dir::lean_ctx_data_dir()
274            .ok()
275            .map(|d| d.join("graphs").join(hash))
276    }
277
278    pub fn load(project_root: &str) -> Option<Self> {
279        let dir = Self::index_dir(project_root)?;
280
281        let zst_path = dir.join("index.json.zst");
282        if zst_path.exists() {
283            let compressed = std::fs::read(&zst_path).ok()?;
284            let data = zstd::decode_all(compressed.as_slice()).ok()?;
285            let content = String::from_utf8(data).ok()?;
286            let index: Self = serde_json::from_str(&content).ok()?;
287            if index.version != INDEX_VERSION {
288                return None;
289            }
290            return Some(index);
291        }
292
293        let json_path = dir.join("index.json");
294        let content = std::fs::read_to_string(&json_path)
295            .or_else(|_| -> std::io::Result<String> {
296                let legacy_hash = short_hash(&normalize_project_root(project_root));
297                let legacy_dir = crate::core::data_dir::lean_ctx_data_dir()
298                    .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no data dir"))?
299                    .join("graphs")
300                    .join(legacy_hash);
301                let legacy_path = legacy_dir.join("index.json");
302                let data = std::fs::read_to_string(&legacy_path)?;
303                if let Err(e) = copy_dir_fallible(&legacy_dir, &dir) {
304                    tracing::debug!("graph index migration: {e}");
305                }
306                Ok(data)
307            })
308            .ok()?;
309        let index: Self = serde_json::from_str(&content).ok()?;
310        if index.version != INDEX_VERSION {
311            return None;
312        }
313        // Auto-migrate: compress legacy JSON to zstd
314        if let Ok(compressed) = zstd::encode_all(content.as_bytes(), 9) {
315            let zst_tmp = zst_path.with_extension("zst.tmp");
316            if std::fs::write(&zst_tmp, &compressed).is_ok()
317                && std::fs::rename(&zst_tmp, &zst_path).is_ok()
318            {
319                let _ = std::fs::remove_file(&json_path);
320            }
321        }
322        Some(index)
323    }
324
325    pub fn save(&self) -> Result<(), String> {
326        let dir = Self::index_dir(&self.project_root)
327            .ok_or_else(|| "Cannot determine data directory".to_string())?;
328        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
329        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
330        let compressed = zstd::encode_all(json.as_bytes(), 9).map_err(|e| format!("zstd: {e}"))?;
331        let target = dir.join("index.json.zst");
332        let tmp = target.with_extension("zst.tmp");
333        std::fs::write(&tmp, &compressed).map_err(|e| e.to_string())?;
334        std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?;
335        let _ = std::fs::remove_file(dir.join("index.json"));
336        Ok(())
337    }
338
339    /// Remove all cached graph indices that are older than max_age_hours.
340    /// Called on startup/update to prevent stale data from persisting.
341    pub fn purge_stale_indices() {
342        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
343            return;
344        };
345        let graphs_dir = data_dir.join("graphs");
346        let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
347            return;
348        };
349        let cfg = crate::core::config::Config::load();
350        let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
351
352        for entry in entries.filter_map(Result::ok) {
353            let path = entry.path();
354            if !path.is_dir() {
355                continue;
356            }
357            let zst = path.join("index.json.zst");
358            let json = path.join("index.json");
359            let index_file = if zst.exists() {
360                &zst
361            } else if json.exists() {
362                &json
363            } else {
364                continue;
365            };
366
367            let is_old = index_file
368                .metadata()
369                .and_then(|m| m.modified())
370                .is_ok_and(|mtime| {
371                    mtime
372                        .elapsed()
373                        .is_ok_and(|age| age.as_secs() > max_age_secs)
374                });
375
376            if is_old {
377                tracing::info!("[graph_index: purging stale index at {}]", path.display());
378                let _ = std::fs::remove_dir_all(&path);
379            }
380        }
381    }
382
383    pub fn file_count(&self) -> usize {
384        self.files.len()
385    }
386
387    pub fn symbol_count(&self) -> usize {
388        self.symbols.len()
389    }
390
391    pub fn edge_count(&self) -> usize {
392        self.edges.len()
393    }
394
395    pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
396        self.symbols.get(key)
397    }
398
399    pub fn get_reverse_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.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
415                    queue.push((edge.from.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        if !idx.files.is_empty() {
475            if index_looks_stale(&idx, &root_abs) {
476                tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
477                return scan(&root_abs);
478            }
479            return idx;
480        }
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 && cwd_str.starts_with(&root_abs) {
487            if let Some(idx) = ProjectIndex::load(&cwd_str) {
488                if !idx.files.is_empty() {
489                    if index_looks_stale(&idx, &cwd_str) {
490                        return scan(&cwd_str);
491                    }
492                    return idx;
493                }
494            }
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.
577fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
578    let dir = ProjectIndex::index_dir(root_abs)?;
579    for name in ["index.json.zst", "index.json"] {
580        if let Ok(meta) = std::fs::metadata(dir.join(name)) {
581            if let Ok(modified) = meta.modified() {
582                return Some(modified);
583            }
584        }
585    }
586    None
587}
588
589/// Bounded staleness check that confirms *content* changes, not just mtimes.
590///
591/// An mtime newer than the persisted index only flags a *candidate*; the change
592/// is then confirmed by comparing the file's content hash against the stored
593/// `FileEntry.hash` (same `compute_hash` + `read_to_string` the scan uses, so
594/// the comparison is exact). This means a `touch`, `git checkout`, or formatter
595/// rewrite that leaves bytes unchanged no longer forces a needless rescan, while
596/// genuine edits and newly added files still mark the index stale.
597///
598/// Both the traversal and the number of confirming reads are capped: exceeding
599/// the read cap returns `true` (conservatively stale) instead of reading an
600/// unbounded amount. Removed files are handled by the earlier existence check.
601fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
602    let Some(index_mtime) = index_file_mtime(root_abs) else {
603        // No persisted index yet — the existence/TTL checks above already decided.
604        return false;
605    };
606    let walker = ignore::WalkBuilder::new(root_abs)
607        .hidden(true)
608        .git_ignore(true)
609        .git_global(true)
610        .git_exclude(true)
611        .require_git(false)
612        .max_depth(Some(20))
613        .filter_entry(crate::core::walk_filter::keep_entry)
614        .build();
615    const MAX_VISIT: usize = 50_000;
616    const MAX_CONFIRM_READS: usize = 4_000;
617    let mut visited = 0usize;
618    let mut confirm_reads = 0usize;
619    for entry in walker.filter_map(std::result::Result::ok) {
620        visited += 1;
621        if visited > MAX_VISIT {
622            break;
623        }
624        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
625            continue;
626        }
627        let path = entry.path();
628        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
629        if !is_indexable_ext(ext) {
630            continue;
631        }
632        // mtime prefilter: only files touched after the index are candidates.
633        let Ok(meta) = entry.metadata() else { continue };
634        let Ok(modified) = meta.modified() else {
635            continue;
636        };
637        if modified <= index_mtime {
638            continue;
639        }
640        // Candidate: confirm against the stored content hash.
641        let rel = make_relative(&path.to_string_lossy(), root_abs);
642        let Some(file_entry) = index.files.get(&rel) else {
643            // A newly added indexable file is genuinely new content.
644            return true;
645        };
646        confirm_reads += 1;
647        if confirm_reads > MAX_CONFIRM_READS {
648            // Too many candidates to verify cheaply — assume stale.
649            return true;
650        }
651        match std::fs::read_to_string(path) {
652            // Bytes unchanged despite a newer mtime → not a real change.
653            Ok(content) if compute_hash(&content) == file_entry.hash => {}
654            // Edited content, or no longer readable as it was at scan time.
655            _ => return true,
656        }
657    }
658    false
659}
660
661/// Delete the persisted graph-index artifacts for a project so the next scan
662/// rebuilds from scratch. Backs `graph build --force`.
663pub fn purge_index(project_root: &str) {
664    if let Some(dir) = ProjectIndex::index_dir(project_root) {
665        for name in ["index.json.zst", "index.json", "call_graph.json.zst"] {
666            let _ = std::fs::remove_file(dir.join(name));
667        }
668    }
669}
670
671pub fn scan(project_root: &str) -> ProjectIndex {
672    scan_inner(project_root).0
673}
674
675pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
676    scan_inner(project_root)
677}
678
679fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
680    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
681        tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
682        return (ProjectIndex::new(project_root), HashMap::new());
683    }
684
685    let project_root = normalize_project_root(project_root);
686
687    if !is_safe_scan_root(&project_root) {
688        tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
689        return (ProjectIndex::new(&project_root), HashMap::new());
690    }
691
692    let lock_name = format!(
693        "graph-idx-{}",
694        &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
695    );
696    let _lock = crate::core::startup_guard::try_acquire_lock(
697        &lock_name,
698        std::time::Duration::from_millis(800),
699        std::time::Duration::from_mins(3),
700    );
701    if _lock.is_none() {
702        tracing::info!(
703            "[graph_index: another process is scanning {project_root} — returning cached or empty]"
704        );
705        return (
706            ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
707            HashMap::new(),
708        );
709    }
710
711    let existing = ProjectIndex::load(&project_root);
712    let mut index = ProjectIndex::new(&project_root);
713
714    let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
715        if let Some(ref prev) = existing {
716            prev.files
717                .iter()
718                .map(|(path, entry)| {
719                    let syms: Vec<(String, SymbolEntry)> = prev
720                        .symbols
721                        .iter()
722                        .filter(|(_, s)| s.file == *path)
723                        .map(|(k, v)| (k.clone(), v.clone()))
724                        .collect();
725                    (path.clone(), (entry.hash.clone(), syms))
726                })
727                .collect()
728        } else {
729            HashMap::new()
730        };
731
732    let walker = ignore::WalkBuilder::new(&project_root)
733        .hidden(true)
734        .git_ignore(true)
735        .git_global(true)
736        .git_exclude(true)
737        .require_git(false)
738        .max_depth(Some(20))
739        .filter_entry(crate::core::walk_filter::keep_entry)
740        .build();
741
742    let cfg = crate::core::config::Config::load();
743    let extra_ignores: Vec<glob::Pattern> = cfg
744        .extra_ignore_patterns
745        .iter()
746        .filter_map(|p| glob::Pattern::new(p).ok())
747        .collect();
748
749    let mut scanned = 0usize;
750    let mut reused = 0usize;
751    let mut entries_visited = 0usize;
752    let mut content_cache: HashMap<String, String> = HashMap::new();
753    let max_files = if cfg.graph_index_max_files == 0 {
754        usize::MAX // unlimited
755    } else {
756        cfg.graph_index_max_files as usize
757    };
758    const MAX_ENTRIES_VISITED: usize = 500_000;
759    const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; // 2 MB per file
760    let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
761
762    for entry in walker.filter_map(std::result::Result::ok) {
763        entries_visited += 1;
764        if entries_visited > MAX_ENTRIES_VISITED {
765            tracing::warn!(
766                "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
767                 runaway traversal. Indexed {} files so far.]",
768                index.files.len()
769            );
770            break;
771        }
772        if entries_visited.is_multiple_of(5000) {
773            if std::time::Instant::now() > scan_deadline {
774                tracing::warn!(
775                    "[graph_index: scan timeout (120s) after {entries_visited} entries — \
776                     saving partial index with {} files]",
777                    index.files.len()
778                );
779                break;
780            }
781            if crate::core::memory_guard::abort_requested() {
782                tracing::warn!(
783                    "[graph_index: memory pressure abort after {entries_visited} entries — \
784                     saving partial index with {} files]",
785                    index.files.len()
786                );
787                break;
788            }
789            if crate::core::memory_guard::is_under_pressure() {
790                tracing::warn!(
791                    "[graph_index: memory pressure detected at {entries_visited} entries — \
792                     stopping scan with {} files]",
793                    index.files.len()
794                );
795                break;
796            }
797            if let Some(ref g) = _lock {
798                g.touch();
799            }
800        }
801
802        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
803            continue;
804        }
805
806        if entry.path_is_symlink() {
807            continue;
808        }
809        let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
810
811        if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
812            continue;
813        }
814
815        if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
816            if meta.file_type().is_symlink() || !meta.is_file() {
817                continue;
818            }
819            if meta.len() > MAX_FILE_SIZE_BYTES {
820                tracing::debug!(
821                    "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
822                    meta.len() as f64 / 1_048_576.0,
823                    MAX_FILE_SIZE_BYTES / (1024 * 1024),
824                );
825                continue;
826            }
827        }
828
829        let ext = Path::new(&file_path)
830            .extension()
831            .and_then(|e| e.to_str())
832            .unwrap_or("");
833
834        if !is_indexable_ext(ext) {
835            continue;
836        }
837
838        let rel = make_relative(&file_path, &project_root);
839        if extra_ignores.iter().any(|p| p.matches(&rel)) {
840            continue;
841        }
842
843        if max_files != usize::MAX && index.files.len() >= max_files {
844            tracing::info!(
845                "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
846                max_files
847            );
848            break;
849        }
850
851        let Ok(content) = std::fs::read_to_string(&file_path) else {
852            continue;
853        };
854
855        let hash = compute_hash(&content);
856        let rel_path = make_relative(&file_path, &project_root);
857
858        if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
859            if *old_hash == hash {
860                if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
861                    index.files.insert(rel_path.clone(), old_entry.clone());
862                    for (key, sym) in old_syms {
863                        index.symbols.insert(key.clone(), sym.clone());
864                    }
865                    content_cache.insert(rel_path, content);
866                    reused += 1;
867                    continue;
868                }
869            }
870        }
871
872        let sigs = signatures::extract_signatures(&content, ext);
873        let line_count = content.lines().count();
874        let token_count = crate::core::tokens::count_tokens(&content);
875        let summary = extract_summary(&content);
876
877        let exports: Vec<String> = sigs
878            .iter()
879            .filter(|s| s.is_exported)
880            .map(|s| s.name.clone())
881            .collect();
882
883        index.files.insert(
884            rel_path.clone(),
885            FileEntry {
886                path: rel_path.clone(),
887                hash,
888                language: ext.to_string(),
889                line_count,
890                token_count,
891                exports,
892                summary,
893            },
894        );
895
896        for sig in &sigs {
897            let (start, end) = sig
898                .start_line
899                .zip(sig.end_line)
900                .unwrap_or_else(|| find_symbol_range(&content, sig));
901            let key = format!("{}::{}", rel_path, sig.name);
902            index.symbols.insert(
903                key,
904                SymbolEntry {
905                    file: rel_path.clone(),
906                    name: sig.name.clone(),
907                    kind: sig.kind.to_string(),
908                    start_line: start,
909                    end_line: end,
910                    is_exported: sig.is_exported,
911                },
912            );
913        }
914
915        content_cache.insert(rel_path, content);
916        scanned += 1;
917    }
918
919    build_edges_cached(&mut index, &content_cache);
920
921    if let Err(e) = index.save() {
922        tracing::warn!("could not save graph index: {e}");
923    }
924
925    tracing::warn!(
926        "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
927        index.file_count(),
928        scanned,
929        reused,
930        index.symbol_count(),
931        index.edge_count()
932    );
933
934    (index, content_cache)
935}
936
937fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
938    let lines: Vec<&str> = content.lines().collect();
939    let mut start = 0;
940
941    for (i, line) in lines.iter().enumerate() {
942        if line.contains(&sig.name) {
943            let trimmed = line.trim();
944            let is_def = trimmed.starts_with("fn ")
945                || trimmed.starts_with("pub fn ")
946                || trimmed.starts_with("pub(crate) fn ")
947                || trimmed.starts_with("async fn ")
948                || trimmed.starts_with("pub async fn ")
949                || trimmed.starts_with("struct ")
950                || trimmed.starts_with("pub struct ")
951                || trimmed.starts_with("enum ")
952                || trimmed.starts_with("pub enum ")
953                || trimmed.starts_with("trait ")
954                || trimmed.starts_with("pub trait ")
955                || trimmed.starts_with("impl ")
956                || trimmed.starts_with("class ")
957                || trimmed.starts_with("export class ")
958                || trimmed.starts_with("export function ")
959                || trimmed.starts_with("export async function ")
960                || trimmed.starts_with("function ")
961                || trimmed.starts_with("async function ")
962                || trimmed.starts_with("def ")
963                || trimmed.starts_with("async def ")
964                || trimmed.starts_with("func ")
965                || trimmed.starts_with("interface ")
966                || trimmed.starts_with("export interface ")
967                || trimmed.starts_with("type ")
968                || trimmed.starts_with("export type ")
969                || trimmed.starts_with("const ")
970                || trimmed.starts_with("export const ")
971                || trimmed.starts_with("fun ")
972                || trimmed.starts_with("private fun ")
973                || trimmed.starts_with("public fun ")
974                || trimmed.starts_with("internal fun ")
975                || trimmed.starts_with("class ")
976                || trimmed.starts_with("data class ")
977                || trimmed.starts_with("sealed class ")
978                || trimmed.starts_with("sealed interface ")
979                || trimmed.starts_with("enum class ")
980                || trimmed.starts_with("object ")
981                || trimmed.starts_with("private object ")
982                || trimmed.starts_with("interface ")
983                || trimmed.starts_with("typealias ")
984                || trimmed.starts_with("private typealias ");
985            if is_def {
986                start = i + 1;
987                break;
988            }
989        }
990    }
991
992    if start == 0 {
993        return (1, lines.len().min(20));
994    }
995
996    let base_indent = lines
997        .get(start - 1)
998        .map_or(0, |l| l.len() - l.trim_start().len());
999
1000    let mut end = start;
1001    let mut brace_depth: i32 = 0;
1002    let mut found_open = false;
1003
1004    for (i, line) in lines.iter().enumerate().skip(start - 1) {
1005        for ch in line.chars() {
1006            if ch == '{' {
1007                brace_depth += 1;
1008                found_open = true;
1009            } else if ch == '}' {
1010                brace_depth -= 1;
1011            }
1012        }
1013
1014        end = i + 1;
1015
1016        if found_open && brace_depth <= 0 {
1017            break;
1018        }
1019
1020        if !found_open && i > start {
1021            let indent = line.len() - line.trim_start().len();
1022            if indent <= base_indent && !line.trim().is_empty() && i > start {
1023                end = i;
1024                break;
1025            }
1026        }
1027
1028        if end - start > 200 {
1029            break;
1030        }
1031    }
1032
1033    (start, end)
1034}
1035
1036fn extract_summary(content: &str) -> String {
1037    for line in content.lines().take(20) {
1038        let trimmed = line.trim();
1039        if trimmed.is_empty()
1040            || trimmed.starts_with("//")
1041            || trimmed.starts_with('#')
1042            || trimmed.starts_with("/*")
1043            || trimmed.starts_with('*')
1044            || trimmed.starts_with("use ")
1045            || trimmed.starts_with("import ")
1046            || trimmed.starts_with("from ")
1047            || trimmed.starts_with("require(")
1048            || trimmed.starts_with("package ")
1049        {
1050            continue;
1051        }
1052        return trimmed.chars().take(120).collect();
1053    }
1054    String::new()
1055}
1056
1057fn compute_hash(content: &str) -> String {
1058    use std::collections::hash_map::DefaultHasher;
1059    use std::hash::{Hash, Hasher};
1060
1061    let mut hasher = DefaultHasher::new();
1062    content.hash(&mut hasher);
1063    format!("{:016x}", hasher.finish())
1064}
1065
1066fn short_hash(input: &str) -> String {
1067    use std::collections::hash_map::DefaultHasher;
1068    use std::hash::{Hash, Hasher};
1069
1070    let mut hasher = DefaultHasher::new();
1071    input.hash(&mut hasher);
1072    format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1073}
1074
1075fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
1076    std::fs::create_dir_all(dst)?;
1077    for entry in std::fs::read_dir(src)?.flatten() {
1078        let from = entry.path();
1079        let to = dst.join(entry.file_name());
1080        if from.is_dir() {
1081            copy_dir_fallible(&from, &to)?;
1082        } else {
1083            std::fs::copy(&from, &to)?;
1084        }
1085    }
1086    Ok(())
1087}
1088
1089fn normalize_absolute_path(path: &str) -> String {
1090    if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
1091        return canon.to_string_lossy().to_string();
1092    }
1093
1094    let mut normalized = path.to_string();
1095    while normalized.ends_with("\\.") || normalized.ends_with("/.") {
1096        normalized.truncate(normalized.len() - 2);
1097    }
1098    while normalized.len() > 1
1099        && (normalized.ends_with('\\') || normalized.ends_with('/'))
1100        && !normalized.ends_with(":\\")
1101        && !normalized.ends_with(":/")
1102        && normalized != "\\"
1103        && normalized != "/"
1104    {
1105        normalized.pop();
1106    }
1107    normalized
1108}
1109
1110pub fn normalize_project_root(path: &str) -> String {
1111    normalize_absolute_path(path)
1112}
1113
1114pub fn graph_match_key(path: &str) -> String {
1115    let stripped =
1116        crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
1117    stripped.trim_start_matches('/').to_string()
1118}
1119
1120pub fn graph_relative_key(path: &str, root: &str) -> String {
1121    let root_norm = normalize_project_root(root);
1122    let path_norm = normalize_absolute_path(path);
1123    let root_path = Path::new(&root_norm);
1124    let path_path = Path::new(&path_norm);
1125
1126    if let Ok(rel) = path_path.strip_prefix(root_path) {
1127        let rel = rel.to_string_lossy().to_string();
1128        return rel.trim_start_matches(['/', '\\']).to_string();
1129    }
1130
1131    path.trim_start_matches(['/', '\\'])
1132        .replace('/', std::path::MAIN_SEPARATOR_STR)
1133}
1134
1135fn make_relative(path: &str, root: &str) -> String {
1136    graph_relative_key(path, root)
1137}
1138
1139fn is_indexable_ext(ext: &str) -> bool {
1140    crate::core::language_capabilities::is_indexable_ext(ext)
1141}
1142
1143#[cfg(test)]
1144fn kotlin_package_name(content: &str) -> Option<String> {
1145    content.lines().map(str::trim).find_map(|line| {
1146        line.strip_prefix("package ")
1147            .map(|rest| rest.trim().trim_end_matches(';').to_string())
1148    })
1149}