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