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