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
30fn dir_has_project_marker(dir: &Path) -> bool {
36 crate::core::pathutil::has_project_marker(dir)
37}
38
39fn 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 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 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 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 "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 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 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 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
199fn 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 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 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(¤t) {
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 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(¤t) {
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(¤t) {
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
478pub 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 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 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 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 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 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 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 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
605fn 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
618fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
631 let Some(index_mtime) = index_file_mtime(root_abs) else {
632 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 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 let rel = make_relative(&path.to_string_lossy(), root_abs);
671 let Some(file_entry) = index.files.get(&rel) else {
672 return true;
674 };
675 confirm_reads += 1;
676 if confirm_reads > MAX_CONFIRM_READS {
677 return true;
679 }
680 match std::fs::read_to_string(path) {
681 Ok(content) if compute_hash(&content) == file_entry.hash => {}
683 _ => return true,
685 }
686 }
687 false
688}
689
690pub 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 } 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; 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}