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    /// Forward import dependencies: files that `path` (transitively) imports.
423    /// Mirror of `get_reverse_deps` with the edge direction flipped.
424    pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
425        let mut result = Vec::new();
426        let mut visited = std::collections::HashSet::new();
427        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
428
429        while let Some((current, d)) = queue.pop() {
430            if d > depth || visited.contains(&current) {
431                continue;
432            }
433            visited.insert(current.clone());
434            if current != path {
435                result.push(current.clone());
436            }
437
438            for edge in &self.edges {
439                if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
440                    queue.push((edge.to.clone(), d + 1));
441                }
442            }
443        }
444        result
445    }
446
447    pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
448        let mut result = Vec::new();
449        let mut visited = std::collections::HashSet::new();
450        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
451
452        while let Some((current, d)) = queue.pop() {
453            if d > depth || visited.contains(&current) {
454                continue;
455            }
456            visited.insert(current.clone());
457            if current != path {
458                result.push(current.clone());
459            }
460
461            for edge in &self.edges {
462                if edge.from == current && !visited.contains(&edge.to) {
463                    queue.push((edge.to.clone(), d + 1));
464                }
465                if edge.to == current && !visited.contains(&edge.from) {
466                    queue.push((edge.from.clone(), d + 1));
467                }
468            }
469        }
470        result
471    }
472}
473
474/// Load the best available graph index, trying multiple root path variants.
475/// If no valid index exists, automatically scans the project to build one.
476/// This is the primary entry point — ensures zero-config usage.
477pub fn load_or_build(project_root: &str) -> ProjectIndex {
478    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
479        return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
480    }
481
482    // Prefer stable absolute roots. Using "." as a cache key is fragile because
483    // it depends on the process cwd and can accidentally load the wrong project.
484    let root_abs = if project_root.trim().is_empty() || project_root == "." {
485        std::env::current_dir().ok().map_or_else(
486            || ".".to_string(),
487            |p| normalize_project_root(&p.to_string_lossy()),
488        )
489    } else {
490        normalize_project_root(project_root)
491    };
492
493    if !is_safe_scan_root(&root_abs) {
494        return ProjectIndex::new(&root_abs);
495    }
496
497    // Try the absolute/root-normalized path first.
498    if let Some(idx) = ProjectIndex::load(&root_abs) {
499        if !idx.files.is_empty() {
500            if index_looks_stale(&idx, &root_abs) {
501                tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
502                return scan(&root_abs);
503            }
504            return idx;
505        }
506    }
507
508    // CWD fallback: only use if CWD is a subdirectory of root_abs (same project)
509    if let Ok(cwd) = std::env::current_dir() {
510        let cwd_str = normalize_project_root(&cwd.to_string_lossy());
511        if cwd_str != root_abs && cwd_str.starts_with(&root_abs) {
512            if let Some(idx) = ProjectIndex::load(&cwd_str) {
513                if !idx.files.is_empty() {
514                    if index_looks_stale(&idx, &cwd_str) {
515                        return scan(&cwd_str);
516                    }
517                    return idx;
518                }
519            }
520        }
521    }
522
523    scan(&root_abs)
524}
525
526fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
527    if index.files.is_empty() {
528        return true;
529    }
530
531    // TTL check: rebuild if index is older than configured max_age_hours
532    if let Ok(scan_time) =
533        chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
534    {
535        let cfg = crate::core::config::Config::load();
536        let effective_hours = cfg.archive_max_age_hours_effective();
537        let max_age = chrono::Duration::hours(effective_hours as i64);
538        let now = chrono::Local::now().naive_local();
539        if now.signed_duration_since(scan_time) > max_age {
540            tracing::info!(
541                "[graph_index: index is older than {}h — marking stale]",
542                effective_hours
543            );
544            return true;
545        }
546    }
547
548    // Contamination check: if index contains paths from common user directories,
549    // it was built from a too-broad root and must be rebuilt
550    const CONTAMINATION_MARKERS: &[&str] = &[
551        "Desktop/",
552        "Documents/",
553        "Downloads/",
554        "Pictures/",
555        "Music/",
556        "Videos/",
557        "Movies/",
558        "Library/",
559        ".cache/",
560        "snap/",
561    ];
562    let contaminated = index.files.keys().take(200).any(|rel| {
563        CONTAMINATION_MARKERS
564            .iter()
565            .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
566    });
567    if contaminated {
568        tracing::warn!(
569            "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
570             marking stale to force clean rebuild]"
571        );
572        return true;
573    }
574
575    let root_path = Path::new(root_abs);
576    // Sample up to 20 files for existence check (avoid scanning all files in large indices)
577    let sample_size = index.files.len().min(20);
578    for rel in index.files.keys().take(sample_size) {
579        let rel = rel.trim_start_matches(['/', '\\']);
580        if rel.is_empty() {
581            continue;
582        }
583        let abs = root_path.join(rel);
584        if !abs.exists() {
585            return true;
586        }
587    }
588
589    // Content-aware staleness: rescan only when source *content* actually
590    // changed. mtime is a cheap prefilter; the change is then confirmed against
591    // the stored content hash so a `touch`/checkout/format that leaves bytes
592    // unchanged never forces a needless rescan (covers edits and new files).
593    if source_content_changed_since_index(index, root_abs) {
594        tracing::info!("[graph_index: source content changed since last scan — marking stale]");
595        return true;
596    }
597
598    false
599}
600
601/// Modified time of the persisted index artifact, if one exists.
602fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
603    let dir = ProjectIndex::index_dir(root_abs)?;
604    for name in ["index.json.zst", "index.json"] {
605        if let Ok(meta) = std::fs::metadata(dir.join(name)) {
606            if let Ok(modified) = meta.modified() {
607                return Some(modified);
608            }
609        }
610    }
611    None
612}
613
614/// Bounded staleness check that confirms *content* changes, not just mtimes.
615///
616/// An mtime newer than the persisted index only flags a *candidate*; the change
617/// is then confirmed by comparing the file's content hash against the stored
618/// `FileEntry.hash` (same `compute_hash` + `read_to_string` the scan uses, so
619/// the comparison is exact). This means a `touch`, `git checkout`, or formatter
620/// rewrite that leaves bytes unchanged no longer forces a needless rescan, while
621/// genuine edits and newly added files still mark the index stale.
622///
623/// Both the traversal and the number of confirming reads are capped: exceeding
624/// the read cap returns `true` (conservatively stale) instead of reading an
625/// unbounded amount. Removed files are handled by the earlier existence check.
626fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
627    let Some(index_mtime) = index_file_mtime(root_abs) else {
628        // No persisted index yet — the existence/TTL checks above already decided.
629        return false;
630    };
631    let walker = ignore::WalkBuilder::new(root_abs)
632        .hidden(true)
633        .git_ignore(true)
634        .git_global(true)
635        .git_exclude(true)
636        .require_git(false)
637        .max_depth(Some(20))
638        .filter_entry(crate::core::walk_filter::keep_entry)
639        .build();
640    const MAX_VISIT: usize = 50_000;
641    const MAX_CONFIRM_READS: usize = 4_000;
642    let mut visited = 0usize;
643    let mut confirm_reads = 0usize;
644    for entry in walker.filter_map(std::result::Result::ok) {
645        visited += 1;
646        if visited > MAX_VISIT {
647            break;
648        }
649        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
650            continue;
651        }
652        let path = entry.path();
653        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
654        if !is_indexable_ext(ext) {
655            continue;
656        }
657        // mtime prefilter: only files touched after the index are candidates.
658        let Ok(meta) = entry.metadata() else { continue };
659        let Ok(modified) = meta.modified() else {
660            continue;
661        };
662        if modified <= index_mtime {
663            continue;
664        }
665        // Candidate: confirm against the stored content hash.
666        let rel = make_relative(&path.to_string_lossy(), root_abs);
667        let Some(file_entry) = index.files.get(&rel) else {
668            // A newly added indexable file is genuinely new content.
669            return true;
670        };
671        confirm_reads += 1;
672        if confirm_reads > MAX_CONFIRM_READS {
673            // Too many candidates to verify cheaply — assume stale.
674            return true;
675        }
676        match std::fs::read_to_string(path) {
677            // Bytes unchanged despite a newer mtime → not a real change.
678            Ok(content) if compute_hash(&content) == file_entry.hash => {}
679            // Edited content, or no longer readable as it was at scan time.
680            _ => return true,
681        }
682    }
683    false
684}
685
686/// Delete the persisted graph-index artifacts for a project so the next scan
687/// rebuilds from scratch. Backs `graph build --force`.
688pub fn purge_index(project_root: &str) {
689    if let Some(dir) = ProjectIndex::index_dir(project_root) {
690        for name in ["index.json.zst", "index.json", "call_graph.json.zst"] {
691            let _ = std::fs::remove_file(dir.join(name));
692        }
693    }
694}
695
696pub fn scan(project_root: &str) -> ProjectIndex {
697    scan_inner(project_root).0
698}
699
700pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
701    scan_inner(project_root)
702}
703
704fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
705    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
706        tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
707        return (ProjectIndex::new(project_root), HashMap::new());
708    }
709
710    let project_root = normalize_project_root(project_root);
711
712    if !is_safe_scan_root(&project_root) {
713        tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
714        return (ProjectIndex::new(&project_root), HashMap::new());
715    }
716
717    let lock_name = format!(
718        "graph-idx-{}",
719        &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
720    );
721    let _lock = crate::core::startup_guard::try_acquire_lock(
722        &lock_name,
723        std::time::Duration::from_millis(800),
724        std::time::Duration::from_mins(3),
725    );
726    if _lock.is_none() {
727        tracing::info!(
728            "[graph_index: another process is scanning {project_root} — returning cached or empty]"
729        );
730        return (
731            ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
732            HashMap::new(),
733        );
734    }
735
736    let existing = ProjectIndex::load(&project_root);
737    let mut index = ProjectIndex::new(&project_root);
738
739    let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
740        if let Some(ref prev) = existing {
741            prev.files
742                .iter()
743                .map(|(path, entry)| {
744                    let syms: Vec<(String, SymbolEntry)> = prev
745                        .symbols
746                        .iter()
747                        .filter(|(_, s)| s.file == *path)
748                        .map(|(k, v)| (k.clone(), v.clone()))
749                        .collect();
750                    (path.clone(), (entry.hash.clone(), syms))
751                })
752                .collect()
753        } else {
754            HashMap::new()
755        };
756
757    let walker = ignore::WalkBuilder::new(&project_root)
758        .hidden(true)
759        .git_ignore(true)
760        .git_global(true)
761        .git_exclude(true)
762        .require_git(false)
763        .max_depth(Some(20))
764        .filter_entry(crate::core::walk_filter::keep_entry)
765        .build();
766
767    let cfg = crate::core::config::Config::load();
768    let extra_ignores: Vec<glob::Pattern> = cfg
769        .extra_ignore_patterns
770        .iter()
771        .filter_map(|p| glob::Pattern::new(p).ok())
772        .collect();
773
774    let mut scanned = 0usize;
775    let mut reused = 0usize;
776    let mut entries_visited = 0usize;
777    let mut content_cache: HashMap<String, String> = HashMap::new();
778    let max_files = if cfg.graph_index_max_files == 0 {
779        usize::MAX // unlimited
780    } else {
781        cfg.graph_index_max_files as usize
782    };
783    const MAX_ENTRIES_VISITED: usize = 500_000;
784    const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; // 2 MB per file
785    let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
786
787    for entry in walker.filter_map(std::result::Result::ok) {
788        entries_visited += 1;
789        if entries_visited > MAX_ENTRIES_VISITED {
790            tracing::warn!(
791                "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
792                 runaway traversal. Indexed {} files so far.]",
793                index.files.len()
794            );
795            break;
796        }
797        if entries_visited.is_multiple_of(5000) {
798            if std::time::Instant::now() > scan_deadline {
799                tracing::warn!(
800                    "[graph_index: scan timeout (120s) after {entries_visited} entries — \
801                     saving partial index with {} files]",
802                    index.files.len()
803                );
804                break;
805            }
806            if crate::core::memory_guard::abort_requested() {
807                tracing::warn!(
808                    "[graph_index: memory pressure abort after {entries_visited} entries — \
809                     saving partial index with {} files]",
810                    index.files.len()
811                );
812                break;
813            }
814            if crate::core::memory_guard::is_under_pressure() {
815                tracing::warn!(
816                    "[graph_index: memory pressure detected at {entries_visited} entries — \
817                     stopping scan with {} files]",
818                    index.files.len()
819                );
820                break;
821            }
822            if let Some(ref g) = _lock {
823                g.touch();
824            }
825        }
826
827        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
828            continue;
829        }
830
831        if entry.path_is_symlink() {
832            continue;
833        }
834        let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
835
836        if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
837            continue;
838        }
839
840        if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
841            if meta.file_type().is_symlink() || !meta.is_file() {
842                continue;
843            }
844            if meta.len() > MAX_FILE_SIZE_BYTES {
845                tracing::debug!(
846                    "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
847                    meta.len() as f64 / 1_048_576.0,
848                    MAX_FILE_SIZE_BYTES / (1024 * 1024),
849                );
850                continue;
851            }
852        }
853
854        let ext = Path::new(&file_path)
855            .extension()
856            .and_then(|e| e.to_str())
857            .unwrap_or("");
858
859        if !is_indexable_ext(ext) {
860            continue;
861        }
862
863        let rel = make_relative(&file_path, &project_root);
864        if extra_ignores.iter().any(|p| p.matches(&rel)) {
865            continue;
866        }
867
868        if max_files != usize::MAX && index.files.len() >= max_files {
869            tracing::info!(
870                "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
871                max_files
872            );
873            break;
874        }
875
876        let Ok(content) = std::fs::read_to_string(&file_path) else {
877            continue;
878        };
879
880        let hash = compute_hash(&content);
881        let rel_path = make_relative(&file_path, &project_root);
882
883        if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
884            if *old_hash == hash {
885                if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
886                    index.files.insert(rel_path.clone(), old_entry.clone());
887                    for (key, sym) in old_syms {
888                        index.symbols.insert(key.clone(), sym.clone());
889                    }
890                    content_cache.insert(rel_path, content);
891                    reused += 1;
892                    continue;
893                }
894            }
895        }
896
897        let sigs = signatures::extract_signatures(&content, ext);
898        let line_count = content.lines().count();
899        let token_count = crate::core::tokens::count_tokens(&content);
900        let summary = extract_summary(&content);
901
902        let exports: Vec<String> = sigs
903            .iter()
904            .filter(|s| s.is_exported)
905            .map(|s| s.name.clone())
906            .collect();
907
908        index.files.insert(
909            rel_path.clone(),
910            FileEntry {
911                path: rel_path.clone(),
912                hash,
913                language: ext.to_string(),
914                line_count,
915                token_count,
916                exports,
917                summary,
918            },
919        );
920
921        for sig in &sigs {
922            let (start, end) = sig
923                .start_line
924                .zip(sig.end_line)
925                .unwrap_or_else(|| find_symbol_range(&content, sig));
926            let key = format!("{}::{}", rel_path, sig.name);
927            index.symbols.insert(
928                key,
929                SymbolEntry {
930                    file: rel_path.clone(),
931                    name: sig.name.clone(),
932                    kind: sig.kind.to_string(),
933                    start_line: start,
934                    end_line: end,
935                    is_exported: sig.is_exported,
936                },
937            );
938        }
939
940        content_cache.insert(rel_path, content);
941        scanned += 1;
942    }
943
944    build_edges_cached(&mut index, &content_cache);
945
946    if let Err(e) = index.save() {
947        tracing::warn!("could not save graph index: {e}");
948    }
949
950    tracing::warn!(
951        "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
952        index.file_count(),
953        scanned,
954        reused,
955        index.symbol_count(),
956        index.edge_count()
957    );
958
959    (index, content_cache)
960}
961
962fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
963    let lines: Vec<&str> = content.lines().collect();
964    let mut start = 0;
965
966    for (i, line) in lines.iter().enumerate() {
967        if line.contains(&sig.name) {
968            let trimmed = line.trim();
969            let is_def = trimmed.starts_with("fn ")
970                || trimmed.starts_with("pub fn ")
971                || trimmed.starts_with("pub(crate) fn ")
972                || trimmed.starts_with("async fn ")
973                || trimmed.starts_with("pub async fn ")
974                || trimmed.starts_with("struct ")
975                || trimmed.starts_with("pub struct ")
976                || trimmed.starts_with("enum ")
977                || trimmed.starts_with("pub enum ")
978                || trimmed.starts_with("trait ")
979                || trimmed.starts_with("pub trait ")
980                || trimmed.starts_with("impl ")
981                || trimmed.starts_with("class ")
982                || trimmed.starts_with("export class ")
983                || trimmed.starts_with("export function ")
984                || trimmed.starts_with("export async function ")
985                || trimmed.starts_with("function ")
986                || trimmed.starts_with("async function ")
987                || trimmed.starts_with("def ")
988                || trimmed.starts_with("async def ")
989                || trimmed.starts_with("func ")
990                || trimmed.starts_with("interface ")
991                || trimmed.starts_with("export interface ")
992                || trimmed.starts_with("type ")
993                || trimmed.starts_with("export type ")
994                || trimmed.starts_with("const ")
995                || trimmed.starts_with("export const ")
996                || trimmed.starts_with("fun ")
997                || trimmed.starts_with("private fun ")
998                || trimmed.starts_with("public fun ")
999                || trimmed.starts_with("internal fun ")
1000                || trimmed.starts_with("class ")
1001                || trimmed.starts_with("data class ")
1002                || trimmed.starts_with("sealed class ")
1003                || trimmed.starts_with("sealed interface ")
1004                || trimmed.starts_with("enum class ")
1005                || trimmed.starts_with("object ")
1006                || trimmed.starts_with("private object ")
1007                || trimmed.starts_with("interface ")
1008                || trimmed.starts_with("typealias ")
1009                || trimmed.starts_with("private typealias ");
1010            if is_def {
1011                start = i + 1;
1012                break;
1013            }
1014        }
1015    }
1016
1017    if start == 0 {
1018        return (1, lines.len().min(20));
1019    }
1020
1021    let base_indent = lines
1022        .get(start - 1)
1023        .map_or(0, |l| l.len() - l.trim_start().len());
1024
1025    let mut end = start;
1026    let mut brace_depth: i32 = 0;
1027    let mut found_open = false;
1028
1029    for (i, line) in lines.iter().enumerate().skip(start - 1) {
1030        for ch in line.chars() {
1031            if ch == '{' {
1032                brace_depth += 1;
1033                found_open = true;
1034            } else if ch == '}' {
1035                brace_depth -= 1;
1036            }
1037        }
1038
1039        end = i + 1;
1040
1041        if found_open && brace_depth <= 0 {
1042            break;
1043        }
1044
1045        if !found_open && i > start {
1046            let indent = line.len() - line.trim_start().len();
1047            if indent <= base_indent && !line.trim().is_empty() && i > start {
1048                end = i;
1049                break;
1050            }
1051        }
1052
1053        if end - start > 200 {
1054            break;
1055        }
1056    }
1057
1058    (start, end)
1059}
1060
1061fn extract_summary(content: &str) -> String {
1062    for line in content.lines().take(20) {
1063        let trimmed = line.trim();
1064        if trimmed.is_empty()
1065            || trimmed.starts_with("//")
1066            || trimmed.starts_with('#')
1067            || trimmed.starts_with("/*")
1068            || trimmed.starts_with('*')
1069            || trimmed.starts_with("use ")
1070            || trimmed.starts_with("import ")
1071            || trimmed.starts_with("from ")
1072            || trimmed.starts_with("require(")
1073            || trimmed.starts_with("package ")
1074        {
1075            continue;
1076        }
1077        return trimmed.chars().take(120).collect();
1078    }
1079    String::new()
1080}
1081
1082fn compute_hash(content: &str) -> String {
1083    use std::collections::hash_map::DefaultHasher;
1084    use std::hash::{Hash, Hasher};
1085
1086    let mut hasher = DefaultHasher::new();
1087    content.hash(&mut hasher);
1088    format!("{:016x}", hasher.finish())
1089}
1090
1091fn short_hash(input: &str) -> String {
1092    use std::collections::hash_map::DefaultHasher;
1093    use std::hash::{Hash, Hasher};
1094
1095    let mut hasher = DefaultHasher::new();
1096    input.hash(&mut hasher);
1097    format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1098}
1099
1100fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
1101    std::fs::create_dir_all(dst)?;
1102    for entry in std::fs::read_dir(src)?.flatten() {
1103        let from = entry.path();
1104        let to = dst.join(entry.file_name());
1105        if from.is_dir() {
1106            copy_dir_fallible(&from, &to)?;
1107        } else {
1108            std::fs::copy(&from, &to)?;
1109        }
1110    }
1111    Ok(())
1112}
1113
1114fn normalize_absolute_path(path: &str) -> String {
1115    if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
1116        return canon.to_string_lossy().to_string();
1117    }
1118
1119    let mut normalized = path.to_string();
1120    while normalized.ends_with("\\.") || normalized.ends_with("/.") {
1121        normalized.truncate(normalized.len() - 2);
1122    }
1123    while normalized.len() > 1
1124        && (normalized.ends_with('\\') || normalized.ends_with('/'))
1125        && !normalized.ends_with(":\\")
1126        && !normalized.ends_with(":/")
1127        && normalized != "\\"
1128        && normalized != "/"
1129    {
1130        normalized.pop();
1131    }
1132    normalized
1133}
1134
1135pub fn normalize_project_root(path: &str) -> String {
1136    normalize_absolute_path(path)
1137}
1138
1139pub fn graph_match_key(path: &str) -> String {
1140    let stripped =
1141        crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
1142    stripped.trim_start_matches('/').to_string()
1143}
1144
1145pub fn graph_relative_key(path: &str, root: &str) -> String {
1146    let root_norm = normalize_project_root(root);
1147    let path_norm = normalize_absolute_path(path);
1148    let root_path = Path::new(&root_norm);
1149    let path_path = Path::new(&path_norm);
1150
1151    if let Ok(rel) = path_path.strip_prefix(root_path) {
1152        let rel = rel.to_string_lossy().to_string();
1153        return rel.trim_start_matches(['/', '\\']).to_string();
1154    }
1155
1156    path.trim_start_matches(['/', '\\'])
1157        .replace('/', std::path::MAIN_SEPARATOR_STR)
1158}
1159
1160fn make_relative(path: &str, root: &str) -> String {
1161    graph_relative_key(path, root)
1162}
1163
1164fn is_indexable_ext(ext: &str) -> bool {
1165    crate::core::language_capabilities::is_indexable_ext(ext)
1166}
1167
1168#[cfg(test)]
1169fn kotlin_package_name(content: &str) -> Option<String> {
1170    content.lines().map(str::trim).find_map(|line| {
1171        line.strip_prefix("package ")
1172            .map(|rest| rest.trim().trim_end_matches(';').to_string())
1173    })
1174}