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