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