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