Skip to main content

lean_ctx/core/graph_index/
mod.rs

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