1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::core::import_resolver;
7use crate::core::signatures;
8
9const INDEX_VERSION: u32 = 6;
10
11pub fn is_safe_scan_root_public(path: &str) -> bool {
12 is_safe_scan_root(path)
13}
14
15fn is_filesystem_root(path: &str) -> bool {
16 let p = Path::new(path);
17 p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
18}
19
20fn is_safe_scan_root(path: &str) -> bool {
21 let normalized = normalize_project_root(path);
22 let p = Path::new(&normalized);
23
24 if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
25 tracing::warn!("[graph_index: refusing to scan filesystem root]");
26 return false;
27 }
28
29 if let Some(home) = dirs::home_dir() {
30 let home_norm = normalize_project_root(&home.to_string_lossy());
31 if normalized == home_norm {
32 tracing::warn!(
33 "[graph_index: refusing to scan home directory {normalized} — \
34 set LEAN_CTX_PROJECT_ROOT or run from inside a project]"
35 );
36 return false;
37 }
38 }
39
40 let breadth_markers = [
41 ".git",
42 "Cargo.toml",
43 "package.json",
44 "go.mod",
45 "pyproject.toml",
46 "setup.py",
47 "Makefile",
48 "CMakeLists.txt",
49 "pnpm-workspace.yaml",
50 ".projectile",
51 "BUILD.bazel",
52 "go.work",
53 ];
54
55 if !breadth_markers.iter().any(|m| p.join(m).exists()) {
56 let child_count = std::fs::read_dir(p).map_or(0, |rd| {
57 rd.filter_map(Result::ok)
58 .filter(|e| e.path().is_dir())
59 .count()
60 });
61 if child_count > 50 {
62 tracing::warn!(
63 "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
64 skipping scan to avoid indexing broad directories]"
65 );
66 return false;
67 }
68 }
69
70 true
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ProjectIndex {
75 pub version: u32,
76 pub project_root: String,
77 pub last_scan: String,
78 pub files: HashMap<String, FileEntry>,
79 pub edges: Vec<IndexEdge>,
80 pub symbols: HashMap<String, SymbolEntry>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileEntry {
85 pub path: String,
86 pub hash: String,
87 pub language: String,
88 pub line_count: usize,
89 pub token_count: usize,
90 pub exports: Vec<String>,
91 pub summary: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SymbolEntry {
96 pub file: String,
97 pub name: String,
98 pub kind: String,
99 pub start_line: usize,
100 pub end_line: usize,
101 pub is_exported: bool,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct IndexEdge {
106 pub from: String,
107 pub to: String,
108 pub kind: String,
109}
110
111impl ProjectIndex {
112 pub fn new(project_root: &str) -> Self {
113 Self {
114 version: INDEX_VERSION,
115 project_root: normalize_project_root(project_root),
116 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
117 files: HashMap::new(),
118 edges: Vec::new(),
119 symbols: HashMap::new(),
120 }
121 }
122
123 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
124 let normalized = normalize_project_root(project_root);
125 let hash = crate::core::project_hash::hash_project_root(&normalized);
126 crate::core::data_dir::lean_ctx_data_dir()
127 .ok()
128 .map(|d| d.join("graphs").join(hash))
129 }
130
131 pub fn load(project_root: &str) -> Option<Self> {
132 let dir = Self::index_dir(project_root)?;
133 let path = dir.join("index.json");
134
135 let content = std::fs::read_to_string(&path)
136 .or_else(|_| -> std::io::Result<String> {
137 let legacy_hash = short_hash(&normalize_project_root(project_root));
138 let legacy_dir = crate::core::data_dir::lean_ctx_data_dir()
139 .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no data dir"))?
140 .join("graphs")
141 .join(legacy_hash);
142 let legacy_path = legacy_dir.join("index.json");
143 let data = std::fs::read_to_string(&legacy_path)?;
144 if let Err(e) = copy_dir_fallible(&legacy_dir, &dir) {
145 tracing::debug!("graph index migration: {e}");
146 }
147 Ok(data)
148 })
149 .ok()?;
150 let index: Self = serde_json::from_str(&content).ok()?;
151 if index.version != INDEX_VERSION {
152 return None;
153 }
154 Some(index)
155 }
156
157 pub fn save(&self) -> Result<(), String> {
158 let dir = Self::index_dir(&self.project_root)
159 .ok_or_else(|| "Cannot determine data directory".to_string())?;
160 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
161 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
162 std::fs::write(dir.join("index.json"), json).map_err(|e| e.to_string())
163 }
164
165 pub fn file_count(&self) -> usize {
166 self.files.len()
167 }
168
169 pub fn symbol_count(&self) -> usize {
170 self.symbols.len()
171 }
172
173 pub fn edge_count(&self) -> usize {
174 self.edges.len()
175 }
176
177 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
178 self.symbols.get(key)
179 }
180
181 pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
182 let mut result = Vec::new();
183 let mut visited = std::collections::HashSet::new();
184 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
185
186 while let Some((current, d)) = queue.pop() {
187 if d > depth || visited.contains(¤t) {
188 continue;
189 }
190 visited.insert(current.clone());
191 if current != path {
192 result.push(current.clone());
193 }
194
195 for edge in &self.edges {
196 if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
197 queue.push((edge.from.clone(), d + 1));
198 }
199 }
200 }
201 result
202 }
203
204 pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
205 let mut result = Vec::new();
206 let mut visited = std::collections::HashSet::new();
207 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
208
209 while let Some((current, d)) = queue.pop() {
210 if d > depth || visited.contains(¤t) {
211 continue;
212 }
213 visited.insert(current.clone());
214 if current != path {
215 result.push(current.clone());
216 }
217
218 for edge in &self.edges {
219 if edge.from == current && !visited.contains(&edge.to) {
220 queue.push((edge.to.clone(), d + 1));
221 }
222 if edge.to == current && !visited.contains(&edge.from) {
223 queue.push((edge.from.clone(), d + 1));
224 }
225 }
226 }
227 result
228 }
229}
230
231pub fn load_or_build(project_root: &str) -> ProjectIndex {
235 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
236 return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
237 }
238
239 let root_abs = if project_root.trim().is_empty() || project_root == "." {
242 std::env::current_dir().ok().map_or_else(
243 || ".".to_string(),
244 |p| normalize_project_root(&p.to_string_lossy()),
245 )
246 } else {
247 normalize_project_root(project_root)
248 };
249
250 if !is_safe_scan_root(&root_abs) {
251 return ProjectIndex::new(&root_abs);
252 }
253
254 if let Some(idx) = ProjectIndex::load(&root_abs) {
256 if !idx.files.is_empty() {
257 if index_looks_stale(&idx, &root_abs) {
258 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
259 return scan(&root_abs);
260 }
261 return idx;
262 }
263 }
264
265 if let Some(idx) = ProjectIndex::load(".") {
268 if !idx.files.is_empty() {
269 let mut migrated = idx;
270 migrated.project_root.clone_from(&root_abs);
271 let _ = migrated.save();
272 if index_looks_stale(&migrated, &root_abs) {
273 tracing::warn!(
274 "[graph_index: stale legacy index detected for {root_abs}; rebuilding]"
275 );
276 return scan(&root_abs);
277 }
278 return migrated;
279 }
280 }
281
282 if let Ok(cwd) = std::env::current_dir() {
284 let cwd_str = normalize_project_root(&cwd.to_string_lossy());
285 if cwd_str != root_abs {
286 if let Some(idx) = ProjectIndex::load(&cwd_str) {
287 if !idx.files.is_empty() {
288 if index_looks_stale(&idx, &cwd_str) {
289 tracing::warn!(
290 "[graph_index: stale index detected for {cwd_str}; rebuilding]"
291 );
292 return scan(&cwd_str);
293 }
294 return idx;
295 }
296 }
297 }
298 }
299
300 scan(&root_abs)
302}
303
304fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
305 if index.files.is_empty() {
306 return true;
307 }
308
309 let root_path = Path::new(root_abs);
310 for rel in index.files.keys() {
311 let rel = rel.trim_start_matches(['/', '\\']);
312 if rel.is_empty() {
313 continue;
314 }
315 let abs = root_path.join(rel);
316 if !abs.exists() {
317 return true;
318 }
319 }
320
321 false
322}
323
324pub fn scan(project_root: &str) -> ProjectIndex {
325 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
326 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
327 return ProjectIndex::new(project_root);
328 }
329
330 let project_root = normalize_project_root(project_root);
331
332 if !is_safe_scan_root(&project_root) {
333 tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
334 return ProjectIndex::new(&project_root);
335 }
336
337 let lock_name = format!(
338 "graph-idx-{}",
339 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
340 );
341 let _lock = crate::core::startup_guard::try_acquire_lock(
342 &lock_name,
343 std::time::Duration::from_millis(800),
344 std::time::Duration::from_mins(3),
345 );
346 if _lock.is_none() {
347 tracing::info!(
348 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
349 );
350 return ProjectIndex::load(&project_root)
351 .unwrap_or_else(|| ProjectIndex::new(&project_root));
352 }
353
354 let existing = ProjectIndex::load(&project_root);
355 let mut index = ProjectIndex::new(&project_root);
356
357 let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
358 if let Some(ref prev) = existing {
359 prev.files
360 .iter()
361 .map(|(path, entry)| {
362 let syms: Vec<(String, SymbolEntry)> = prev
363 .symbols
364 .iter()
365 .filter(|(_, s)| s.file == *path)
366 .map(|(k, v)| (k.clone(), v.clone()))
367 .collect();
368 (path.clone(), (entry.hash.clone(), syms))
369 })
370 .collect()
371 } else {
372 HashMap::new()
373 };
374
375 let walker = ignore::WalkBuilder::new(&project_root)
376 .hidden(true)
377 .git_ignore(true)
378 .git_global(true)
379 .git_exclude(true)
380 .max_depth(Some(10))
381 .build();
382
383 let cfg = crate::core::config::Config::load();
384 let extra_ignores: Vec<glob::Pattern> = cfg
385 .extra_ignore_patterns
386 .iter()
387 .filter_map(|p| glob::Pattern::new(p).ok())
388 .collect();
389
390 let mut scanned = 0usize;
391 let mut reused = 0usize;
392 let mut entries_visited = 0usize;
393 let max_files = cfg.graph_index_max_files as usize;
394 const MAX_ENTRIES_VISITED: usize = 50_000;
395 let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(2);
396
397 for entry in walker.filter_map(std::result::Result::ok) {
398 entries_visited += 1;
399 if entries_visited > MAX_ENTRIES_VISITED {
400 tracing::warn!(
401 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
402 runaway traversal. Indexed {} files so far.]",
403 index.files.len()
404 );
405 break;
406 }
407 if entries_visited.is_multiple_of(5000) {
408 if std::time::Instant::now() > scan_deadline {
409 tracing::warn!(
410 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
411 saving partial index with {} files]",
412 index.files.len()
413 );
414 break;
415 }
416 if let Some(ref g) = _lock {
417 g.touch();
418 }
419 }
420
421 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
422 continue;
423 }
424 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
425 let ext = Path::new(&file_path)
426 .extension()
427 .and_then(|e| e.to_str())
428 .unwrap_or("");
429
430 if !is_indexable_ext(ext) {
431 continue;
432 }
433
434 let rel = make_relative(&file_path, &project_root);
435 if extra_ignores.iter().any(|p| p.matches(&rel)) {
436 continue;
437 }
438
439 if index.files.len() >= max_files {
440 tracing::warn!(
441 "Graph index capped at {} files. Increase graph_index_max_files in config.toml for full coverage.",
442 max_files
443 );
444 break;
445 }
446
447 let Ok(content) = std::fs::read_to_string(&file_path) else {
448 continue;
449 };
450
451 let hash = compute_hash(&content);
452 let rel_path = make_relative(&file_path, &project_root);
453
454 if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
455 if *old_hash == hash {
456 if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
457 index.files.insert(rel_path.clone(), old_entry.clone());
458 for (key, sym) in old_syms {
459 index.symbols.insert(key.clone(), sym.clone());
460 }
461 reused += 1;
462 continue;
463 }
464 }
465 }
466
467 let sigs = signatures::extract_signatures(&content, ext);
468 let line_count = content.lines().count();
469 let token_count = crate::core::tokens::count_tokens(&content);
470 let summary = extract_summary(&content);
471
472 let exports: Vec<String> = sigs
473 .iter()
474 .filter(|s| s.is_exported)
475 .map(|s| s.name.clone())
476 .collect();
477
478 index.files.insert(
479 rel_path.clone(),
480 FileEntry {
481 path: rel_path.clone(),
482 hash,
483 language: ext.to_string(),
484 line_count,
485 token_count,
486 exports,
487 summary,
488 },
489 );
490
491 for sig in &sigs {
492 let (start, end) = sig
493 .start_line
494 .zip(sig.end_line)
495 .unwrap_or_else(|| find_symbol_range(&content, sig));
496 let key = format!("{}::{}", rel_path, sig.name);
497 index.symbols.insert(
498 key,
499 SymbolEntry {
500 file: rel_path.clone(),
501 name: sig.name.clone(),
502 kind: sig.kind.to_string(),
503 start_line: start,
504 end_line: end,
505 is_exported: sig.is_exported,
506 },
507 );
508 }
509
510 scanned += 1;
511 }
512
513 build_edges(&mut index);
514
515 if let Err(e) = index.save() {
516 tracing::warn!("could not save graph index: {e}");
517 }
518
519 tracing::warn!(
520 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
521 index.file_count(),
522 scanned,
523 reused,
524 index.symbol_count(),
525 index.edge_count()
526 );
527
528 index
529}
530
531fn build_edges(index: &mut ProjectIndex) {
532 index.edges.clear();
533
534 let root = normalize_project_root(&index.project_root);
535 let root_path = Path::new(&root);
536
537 let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
538 file_paths.sort();
539
540 let resolver_ctx = import_resolver::ResolverContext::new(root_path, file_paths.clone());
541
542 for rel_path in &file_paths {
543 let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
544 let Ok(content) = std::fs::read_to_string(&abs_path) else {
545 continue;
546 };
547
548 let ext = Path::new(rel_path)
549 .extension()
550 .and_then(|e| e.to_str())
551 .unwrap_or("");
552
553 let resolve_ext = match ext {
554 "vue" | "svelte" => "ts",
555 _ => ext,
556 };
557
558 let imports = crate::core::deep_queries::analyze(&content, resolve_ext).imports;
559 if imports.is_empty() {
560 continue;
561 }
562
563 let resolved =
564 import_resolver::resolve_imports(&imports, rel_path, resolve_ext, &resolver_ctx);
565 for r in resolved {
566 if r.is_external {
567 continue;
568 }
569 if let Some(to) = r.resolved_path {
570 index.edges.push(IndexEdge {
571 from: rel_path.clone(),
572 to,
573 kind: "import".to_string(),
574 });
575 }
576 }
577 }
578
579 index.edges.sort_by(|a, b| {
580 a.from
581 .cmp(&b.from)
582 .then_with(|| a.to.cmp(&b.to))
583 .then_with(|| a.kind.cmp(&b.kind))
584 });
585 index
586 .edges
587 .dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
588}
589
590fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
591 let lines: Vec<&str> = content.lines().collect();
592 let mut start = 0;
593
594 for (i, line) in lines.iter().enumerate() {
595 if line.contains(&sig.name) {
596 let trimmed = line.trim();
597 let is_def = trimmed.starts_with("fn ")
598 || trimmed.starts_with("pub fn ")
599 || trimmed.starts_with("pub(crate) fn ")
600 || trimmed.starts_with("async fn ")
601 || trimmed.starts_with("pub async fn ")
602 || trimmed.starts_with("struct ")
603 || trimmed.starts_with("pub struct ")
604 || trimmed.starts_with("enum ")
605 || trimmed.starts_with("pub enum ")
606 || trimmed.starts_with("trait ")
607 || trimmed.starts_with("pub trait ")
608 || trimmed.starts_with("impl ")
609 || trimmed.starts_with("class ")
610 || trimmed.starts_with("export class ")
611 || trimmed.starts_with("export function ")
612 || trimmed.starts_with("export async function ")
613 || trimmed.starts_with("function ")
614 || trimmed.starts_with("async function ")
615 || trimmed.starts_with("def ")
616 || trimmed.starts_with("async def ")
617 || trimmed.starts_with("func ")
618 || trimmed.starts_with("interface ")
619 || trimmed.starts_with("export interface ")
620 || trimmed.starts_with("type ")
621 || trimmed.starts_with("export type ")
622 || trimmed.starts_with("const ")
623 || trimmed.starts_with("export const ")
624 || trimmed.starts_with("fun ")
625 || trimmed.starts_with("private fun ")
626 || trimmed.starts_with("public fun ")
627 || trimmed.starts_with("internal fun ")
628 || trimmed.starts_with("class ")
629 || trimmed.starts_with("data class ")
630 || trimmed.starts_with("sealed class ")
631 || trimmed.starts_with("sealed interface ")
632 || trimmed.starts_with("enum class ")
633 || trimmed.starts_with("object ")
634 || trimmed.starts_with("private object ")
635 || trimmed.starts_with("interface ")
636 || trimmed.starts_with("typealias ")
637 || trimmed.starts_with("private typealias ");
638 if is_def {
639 start = i + 1;
640 break;
641 }
642 }
643 }
644
645 if start == 0 {
646 return (1, lines.len().min(20));
647 }
648
649 let base_indent = lines
650 .get(start - 1)
651 .map_or(0, |l| l.len() - l.trim_start().len());
652
653 let mut end = start;
654 let mut brace_depth: i32 = 0;
655 let mut found_open = false;
656
657 for (i, line) in lines.iter().enumerate().skip(start - 1) {
658 for ch in line.chars() {
659 if ch == '{' {
660 brace_depth += 1;
661 found_open = true;
662 } else if ch == '}' {
663 brace_depth -= 1;
664 }
665 }
666
667 end = i + 1;
668
669 if found_open && brace_depth <= 0 {
670 break;
671 }
672
673 if !found_open && i > start {
674 let indent = line.len() - line.trim_start().len();
675 if indent <= base_indent && !line.trim().is_empty() && i > start {
676 end = i;
677 break;
678 }
679 }
680
681 if end - start > 200 {
682 break;
683 }
684 }
685
686 (start, end)
687}
688
689fn extract_summary(content: &str) -> String {
690 for line in content.lines().take(20) {
691 let trimmed = line.trim();
692 if trimmed.is_empty()
693 || trimmed.starts_with("//")
694 || trimmed.starts_with('#')
695 || trimmed.starts_with("/*")
696 || trimmed.starts_with('*')
697 || trimmed.starts_with("use ")
698 || trimmed.starts_with("import ")
699 || trimmed.starts_with("from ")
700 || trimmed.starts_with("require(")
701 || trimmed.starts_with("package ")
702 {
703 continue;
704 }
705 return trimmed.chars().take(120).collect();
706 }
707 String::new()
708}
709
710fn compute_hash(content: &str) -> String {
711 use std::collections::hash_map::DefaultHasher;
712 use std::hash::{Hash, Hasher};
713
714 let mut hasher = DefaultHasher::new();
715 content.hash(&mut hasher);
716 format!("{:016x}", hasher.finish())
717}
718
719fn short_hash(input: &str) -> String {
720 use std::collections::hash_map::DefaultHasher;
721 use std::hash::{Hash, Hasher};
722
723 let mut hasher = DefaultHasher::new();
724 input.hash(&mut hasher);
725 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
726}
727
728fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
729 std::fs::create_dir_all(dst)?;
730 for entry in std::fs::read_dir(src)?.flatten() {
731 let from = entry.path();
732 let to = dst.join(entry.file_name());
733 if from.is_dir() {
734 copy_dir_fallible(&from, &to)?;
735 } else {
736 std::fs::copy(&from, &to)?;
737 }
738 }
739 Ok(())
740}
741
742fn normalize_absolute_path(path: &str) -> String {
743 if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
744 return canon.to_string_lossy().to_string();
745 }
746
747 let mut normalized = path.to_string();
748 while normalized.ends_with("\\.") || normalized.ends_with("/.") {
749 normalized.truncate(normalized.len() - 2);
750 }
751 while normalized.len() > 1
752 && (normalized.ends_with('\\') || normalized.ends_with('/'))
753 && !normalized.ends_with(":\\")
754 && !normalized.ends_with(":/")
755 && normalized != "\\"
756 && normalized != "/"
757 {
758 normalized.pop();
759 }
760 normalized
761}
762
763pub fn normalize_project_root(path: &str) -> String {
764 normalize_absolute_path(path)
765}
766
767pub fn graph_match_key(path: &str) -> String {
768 let stripped =
769 crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
770 stripped.trim_start_matches('/').to_string()
771}
772
773pub fn graph_relative_key(path: &str, root: &str) -> String {
774 let root_norm = normalize_project_root(root);
775 let path_norm = normalize_absolute_path(path);
776 let root_path = Path::new(&root_norm);
777 let path_path = Path::new(&path_norm);
778
779 if let Ok(rel) = path_path.strip_prefix(root_path) {
780 let rel = rel.to_string_lossy().to_string();
781 return rel.trim_start_matches(['/', '\\']).to_string();
782 }
783
784 path.trim_start_matches(['/', '\\'])
785 .replace('/', std::path::MAIN_SEPARATOR_STR)
786}
787
788fn make_relative(path: &str, root: &str) -> String {
789 graph_relative_key(path, root)
790}
791
792fn is_indexable_ext(ext: &str) -> bool {
793 crate::core::language_capabilities::is_indexable_ext(ext)
794}
795
796#[cfg(test)]
797fn kotlin_package_name(content: &str) -> Option<String> {
798 content.lines().map(str::trim).find_map(|line| {
799 line.strip_prefix("package ")
800 .map(|rest| rest.trim().trim_end_matches(';').to_string())
801 })
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use tempfile::tempdir;
808
809 #[test]
810 fn test_short_hash_deterministic() {
811 let h1 = short_hash("/Users/test/project");
812 let h2 = short_hash("/Users/test/project");
813 assert_eq!(h1, h2);
814 assert_eq!(h1.len(), 8);
815 }
816
817 #[test]
818 fn test_make_relative() {
819 assert_eq!(
820 make_relative("/foo/bar/src/main.rs", "/foo/bar"),
821 graph_relative_key("/foo/bar/src/main.rs", "/foo/bar")
822 );
823 assert_eq!(
824 make_relative("src/main.rs", "/foo/bar"),
825 graph_relative_key("src/main.rs", "/foo/bar")
826 );
827 assert_eq!(
828 make_relative("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo"),
829 graph_relative_key("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo")
830 );
831 assert_eq!(
832 make_relative("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo"),
833 graph_relative_key("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo")
834 );
835 }
836
837 #[test]
838 fn test_normalize_project_root() {
839 assert_eq!(normalize_project_root("C:\\repo\\"), "C:\\repo");
840 assert_eq!(normalize_project_root("C:\\repo\\."), "C:\\repo");
841 assert_eq!(normalize_project_root("//?/C:/repo/"), "//?/C:/repo");
842 }
843
844 #[test]
845 fn test_graph_match_key_normalizes_windows_forms() {
846 assert_eq!(
847 graph_match_key(r"C:\repo\src\main.rs"),
848 "C:/repo/src/main.rs"
849 );
850 assert_eq!(
851 graph_match_key(r"\\?\C:\repo\src\main.rs"),
852 "C:/repo/src/main.rs"
853 );
854 assert_eq!(graph_match_key(r"\src\main.rs"), "src/main.rs");
855 }
856
857 #[test]
858 fn test_extract_summary() {
859 let content = "// comment\nuse std::io;\n\npub fn main() {\n println!(\"hello\");\n}";
860 let summary = extract_summary(content);
861 assert_eq!(summary, "pub fn main() {");
862 }
863
864 #[test]
865 fn test_compute_hash_deterministic() {
866 let h1 = compute_hash("hello world");
867 let h2 = compute_hash("hello world");
868 assert_eq!(h1, h2);
869 assert_ne!(h1, compute_hash("hello world!"));
870 }
871
872 #[test]
873 fn test_project_index_new() {
874 let idx = ProjectIndex::new("/test");
875 assert_eq!(idx.version, INDEX_VERSION);
876 assert_eq!(idx.project_root, "/test");
877 assert!(idx.files.is_empty());
878 }
879
880 fn fe(path: &str, content: &str, language: &str) -> FileEntry {
881 FileEntry {
882 path: path.to_string(),
883 hash: compute_hash(content),
884 language: language.to_string(),
885 line_count: content.lines().count(),
886 token_count: crate::core::tokens::count_tokens(content),
887 exports: Vec::new(),
888 summary: extract_summary(content),
889 }
890 }
891
892 #[test]
893 fn test_index_looks_stale_when_any_file_missing() {
894 let td = tempdir().expect("tempdir");
895 let root = td.path();
896 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
897
898 let root_s = normalize_project_root(&root.to_string_lossy());
899 let mut idx = ProjectIndex::new(&root_s);
900 idx.files
901 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
902 idx.files.insert(
903 "missing.rs".to_string(),
904 fe("missing.rs", "pub fn m() {}\n", "rs"),
905 );
906
907 assert!(index_looks_stale(&idx, &root_s));
908 }
909
910 #[test]
911 fn test_index_looks_fresh_when_all_files_exist() {
912 let td = tempdir().expect("tempdir");
913 let root = td.path();
914 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
915
916 let root_s = normalize_project_root(&root.to_string_lossy());
917 let mut idx = ProjectIndex::new(&root_s);
918 idx.files
919 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
920
921 assert!(!index_looks_stale(&idx, &root_s));
922 }
923
924 #[test]
925 fn test_reverse_deps() {
926 let mut idx = ProjectIndex::new("/test");
927 idx.edges.push(IndexEdge {
928 from: "a.rs".to_string(),
929 to: "b.rs".to_string(),
930 kind: "import".to_string(),
931 });
932 idx.edges.push(IndexEdge {
933 from: "c.rs".to_string(),
934 to: "b.rs".to_string(),
935 kind: "import".to_string(),
936 });
937
938 let deps = idx.get_reverse_deps("b.rs", 1);
939 assert_eq!(deps.len(), 2);
940 assert!(deps.contains(&"a.rs".to_string()));
941 assert!(deps.contains(&"c.rs".to_string()));
942 }
943
944 #[test]
945 fn test_find_symbol_range_kotlin_function() {
946 let content = r#"
947package com.example
948
949class UserService {
950 fun greet(name: String): String {
951 return "hi $name"
952 }
953}
954"#;
955 let sig = signatures::Signature {
956 kind: "method",
957 name: "greet".to_string(),
958 params: "name:String".to_string(),
959 return_type: "String".to_string(),
960 is_async: false,
961 is_exported: true,
962 indent: 2,
963 ..signatures::Signature::no_span()
964 };
965 let (start, end) = find_symbol_range(content, &sig);
966 assert_eq!(start, 5);
967 assert!(end >= start);
968 }
969
970 #[test]
971 fn test_signature_spans_override_fallback_range() {
972 let sig = signatures::Signature {
973 kind: "method",
974 name: "release".to_string(),
975 params: "id:String".to_string(),
976 return_type: "Boolean".to_string(),
977 is_async: true,
978 is_exported: true,
979 indent: 2,
980 start_line: Some(42),
981 end_line: Some(43),
982 };
983
984 let (start, end) = sig
985 .start_line
986 .zip(sig.end_line)
987 .unwrap_or_else(|| find_symbol_range("ignored", &sig));
988 assert_eq!((start, end), (42, 43));
989 }
990
991 #[test]
992 fn test_parse_stale_index_version() {
993 let json = format!(
994 r#"{{"version":{},"project_root":"/test","last_scan":"now","files":{{}},"edges":[],"symbols":{{}}}}"#,
995 INDEX_VERSION - 1
996 );
997 let parsed: ProjectIndex = serde_json::from_str(&json).unwrap();
998 assert_ne!(parsed.version, INDEX_VERSION);
999 }
1000
1001 #[test]
1002 fn test_kotlin_package_name() {
1003 let content = "package com.example.feature\n\nclass UserService";
1004 assert_eq!(
1005 kotlin_package_name(content).as_deref(),
1006 Some("com.example.feature")
1007 );
1008 }
1009
1010 #[test]
1011 fn safe_scan_root_rejects_fs_root() {
1012 assert!(!is_safe_scan_root("/"));
1013 assert!(!is_safe_scan_root("\\"));
1014 #[cfg(windows)]
1015 {
1016 assert!(!is_safe_scan_root("C:\\"));
1017 assert!(!is_safe_scan_root("D:\\"));
1018 }
1019 }
1020
1021 #[test]
1022 fn safe_scan_root_rejects_home() {
1023 if let Some(home) = dirs::home_dir() {
1024 let home_str = home.to_string_lossy().to_string();
1025 assert!(
1026 !is_safe_scan_root(&home_str),
1027 "home dir should be rejected: {home_str}"
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn safe_scan_root_accepts_project_dir() {
1034 let tmp = tempdir().unwrap();
1035 std::fs::write(
1036 tmp.path().join("Cargo.toml"),
1037 "[package]\nname = \"test\"\n",
1038 )
1039 .unwrap();
1040 let root = tmp.path().to_string_lossy().to_string();
1041 assert!(is_safe_scan_root(&root));
1042 }
1043
1044 #[test]
1045 fn safe_scan_root_rejects_broad_dir() {
1046 let tmp = tempdir().unwrap();
1047 for i in 0..55 {
1048 std::fs::create_dir(tmp.path().join(format!("dir{i}"))).unwrap();
1049 }
1050 let root = tmp.path().to_string_lossy().to_string();
1051 assert!(!is_safe_scan_root(&root));
1052 }
1053
1054 #[test]
1055 fn no_index_env_skips_scan() {
1056 let _env = crate::core::data_dir::test_env_lock();
1057 let tmp = tempdir().unwrap();
1058 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1059 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
1060
1061 std::env::set_var("LEAN_CTX_NO_INDEX", "1");
1062 let idx = scan(&tmp.path().to_string_lossy());
1063 std::env::remove_var("LEAN_CTX_NO_INDEX");
1064 assert!(idx.files.is_empty(), "LEAN_CTX_NO_INDEX should skip scan");
1065 }
1066}