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
11#[derive(Debug, Serialize, Deserialize)]
12pub struct ProjectIndex {
13 pub version: u32,
14 pub project_root: String,
15 pub last_scan: String,
16 pub files: HashMap<String, FileEntry>,
17 pub edges: Vec<IndexEdge>,
18 pub symbols: HashMap<String, SymbolEntry>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileEntry {
23 pub path: String,
24 pub hash: String,
25 pub language: String,
26 pub line_count: usize,
27 pub token_count: usize,
28 pub exports: Vec<String>,
29 pub summary: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SymbolEntry {
34 pub file: String,
35 pub name: String,
36 pub kind: String,
37 pub start_line: usize,
38 pub end_line: usize,
39 pub is_exported: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct IndexEdge {
44 pub from: String,
45 pub to: String,
46 pub kind: String,
47}
48
49impl ProjectIndex {
50 pub fn new(project_root: &str) -> Self {
51 Self {
52 version: INDEX_VERSION,
53 project_root: normalize_project_root(project_root),
54 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
55 files: HashMap::new(),
56 edges: Vec::new(),
57 symbols: HashMap::new(),
58 }
59 }
60
61 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
62 let normalized = normalize_project_root(project_root);
63 let hash = crate::core::project_hash::hash_project_root(&normalized);
64 crate::core::data_dir::lean_ctx_data_dir()
65 .ok()
66 .map(|d| d.join("graphs").join(hash))
67 }
68
69 pub fn load(project_root: &str) -> Option<Self> {
70 let dir = Self::index_dir(project_root)?;
71 let path = dir.join("index.json");
72
73 let content = std::fs::read_to_string(&path)
74 .or_else(|_| -> std::io::Result<String> {
75 let legacy_hash = short_hash(&normalize_project_root(project_root));
76 let legacy_dir = crate::core::data_dir::lean_ctx_data_dir()
77 .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no data dir"))?
78 .join("graphs")
79 .join(legacy_hash);
80 let legacy_path = legacy_dir.join("index.json");
81 let data = std::fs::read_to_string(&legacy_path)?;
82 if let Err(e) = copy_dir_fallible(&legacy_dir, &dir) {
83 tracing::debug!("graph index migration: {e}");
84 }
85 Ok(data)
86 })
87 .ok()?;
88 let index: Self = serde_json::from_str(&content).ok()?;
89 if index.version != INDEX_VERSION {
90 return None;
91 }
92 Some(index)
93 }
94
95 pub fn save(&self) -> Result<(), String> {
96 let dir = Self::index_dir(&self.project_root)
97 .ok_or_else(|| "Cannot determine data directory".to_string())?;
98 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
99 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
100 std::fs::write(dir.join("index.json"), json).map_err(|e| e.to_string())
101 }
102
103 pub fn file_count(&self) -> usize {
104 self.files.len()
105 }
106
107 pub fn symbol_count(&self) -> usize {
108 self.symbols.len()
109 }
110
111 pub fn edge_count(&self) -> usize {
112 self.edges.len()
113 }
114
115 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
116 self.symbols.get(key)
117 }
118
119 pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
120 let mut result = Vec::new();
121 let mut visited = std::collections::HashSet::new();
122 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
123
124 while let Some((current, d)) = queue.pop() {
125 if d > depth || visited.contains(¤t) {
126 continue;
127 }
128 visited.insert(current.clone());
129 if current != path {
130 result.push(current.clone());
131 }
132
133 for edge in &self.edges {
134 if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
135 queue.push((edge.from.clone(), d + 1));
136 }
137 }
138 }
139 result
140 }
141
142 pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
143 let mut result = Vec::new();
144 let mut visited = std::collections::HashSet::new();
145 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
146
147 while let Some((current, d)) = queue.pop() {
148 if d > depth || visited.contains(¤t) {
149 continue;
150 }
151 visited.insert(current.clone());
152 if current != path {
153 result.push(current.clone());
154 }
155
156 for edge in &self.edges {
157 if edge.from == current && !visited.contains(&edge.to) {
158 queue.push((edge.to.clone(), d + 1));
159 }
160 if edge.to == current && !visited.contains(&edge.from) {
161 queue.push((edge.from.clone(), d + 1));
162 }
163 }
164 }
165 result
166 }
167}
168
169pub fn load_or_build(project_root: &str) -> ProjectIndex {
173 let root_abs = if project_root.trim().is_empty() || project_root == "." {
176 std::env::current_dir().ok().map_or_else(
177 || ".".to_string(),
178 |p| normalize_project_root(&p.to_string_lossy()),
179 )
180 } else {
181 normalize_project_root(project_root)
182 };
183
184 if let Some(idx) = ProjectIndex::load(&root_abs) {
186 if !idx.files.is_empty() {
187 if index_looks_stale(&idx, &root_abs) {
188 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
189 return scan(&root_abs);
190 }
191 return idx;
192 }
193 }
194
195 if let Some(idx) = ProjectIndex::load(".") {
198 if !idx.files.is_empty() {
199 let mut migrated = idx;
200 migrated.project_root.clone_from(&root_abs);
201 let _ = migrated.save();
202 if index_looks_stale(&migrated, &root_abs) {
203 tracing::warn!(
204 "[graph_index: stale legacy index detected for {root_abs}; rebuilding]"
205 );
206 return scan(&root_abs);
207 }
208 return migrated;
209 }
210 }
211
212 if let Ok(cwd) = std::env::current_dir() {
214 let cwd_str = normalize_project_root(&cwd.to_string_lossy());
215 if cwd_str != root_abs {
216 if let Some(idx) = ProjectIndex::load(&cwd_str) {
217 if !idx.files.is_empty() {
218 if index_looks_stale(&idx, &cwd_str) {
219 tracing::warn!(
220 "[graph_index: stale index detected for {cwd_str}; rebuilding]"
221 );
222 return scan(&cwd_str);
223 }
224 return idx;
225 }
226 }
227 }
228 }
229
230 scan(&root_abs)
232}
233
234fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
235 if index.files.is_empty() {
236 return true;
237 }
238
239 let root_path = Path::new(root_abs);
240 for rel in index.files.keys() {
241 let rel = rel.trim_start_matches(['/', '\\']);
242 if rel.is_empty() {
243 continue;
244 }
245 let abs = root_path.join(rel);
246 if !abs.exists() {
247 return true;
248 }
249 }
250
251 false
252}
253
254pub fn scan(project_root: &str) -> ProjectIndex {
255 let project_root = normalize_project_root(project_root);
256 let existing = ProjectIndex::load(&project_root);
257 let mut index = ProjectIndex::new(&project_root);
258
259 let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
260 if let Some(ref prev) = existing {
261 prev.files
262 .iter()
263 .map(|(path, entry)| {
264 let syms: Vec<(String, SymbolEntry)> = prev
265 .symbols
266 .iter()
267 .filter(|(_, s)| s.file == *path)
268 .map(|(k, v)| (k.clone(), v.clone()))
269 .collect();
270 (path.clone(), (entry.hash.clone(), syms))
271 })
272 .collect()
273 } else {
274 HashMap::new()
275 };
276
277 let walker = ignore::WalkBuilder::new(&project_root)
278 .hidden(true)
279 .git_ignore(true)
280 .git_global(true)
281 .git_exclude(true)
282 .max_depth(Some(10))
283 .build();
284
285 let cfg = crate::core::config::Config::load();
286 let extra_ignores: Vec<glob::Pattern> = cfg
287 .extra_ignore_patterns
288 .iter()
289 .filter_map(|p| glob::Pattern::new(p).ok())
290 .collect();
291
292 let mut scanned = 0usize;
293 let mut reused = 0usize;
294 let max_files = 2000;
295
296 for entry in walker.filter_map(std::result::Result::ok) {
297 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
298 continue;
299 }
300 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
301 let ext = Path::new(&file_path)
302 .extension()
303 .and_then(|e| e.to_str())
304 .unwrap_or("");
305
306 if !is_indexable_ext(ext) {
307 continue;
308 }
309
310 let rel = make_relative(&file_path, &project_root);
311 if extra_ignores.iter().any(|p| p.matches(&rel)) {
312 continue;
313 }
314
315 if index.files.len() >= max_files {
316 break;
317 }
318
319 let Ok(content) = std::fs::read_to_string(&file_path) else {
320 continue;
321 };
322
323 let hash = compute_hash(&content);
324 let rel_path = make_relative(&file_path, &project_root);
325
326 if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
327 if *old_hash == hash {
328 if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
329 index.files.insert(rel_path.clone(), old_entry.clone());
330 for (key, sym) in old_syms {
331 index.symbols.insert(key.clone(), sym.clone());
332 }
333 reused += 1;
334 continue;
335 }
336 }
337 }
338
339 let sigs = signatures::extract_signatures(&content, ext);
340 let line_count = content.lines().count();
341 let token_count = crate::core::tokens::count_tokens(&content);
342 let summary = extract_summary(&content);
343
344 let exports: Vec<String> = sigs
345 .iter()
346 .filter(|s| s.is_exported)
347 .map(|s| s.name.clone())
348 .collect();
349
350 index.files.insert(
351 rel_path.clone(),
352 FileEntry {
353 path: rel_path.clone(),
354 hash,
355 language: ext.to_string(),
356 line_count,
357 token_count,
358 exports,
359 summary,
360 },
361 );
362
363 for sig in &sigs {
364 let (start, end) = sig
365 .start_line
366 .zip(sig.end_line)
367 .unwrap_or_else(|| find_symbol_range(&content, sig));
368 let key = format!("{}::{}", rel_path, sig.name);
369 index.symbols.insert(
370 key,
371 SymbolEntry {
372 file: rel_path.clone(),
373 name: sig.name.clone(),
374 kind: sig.kind.to_string(),
375 start_line: start,
376 end_line: end,
377 is_exported: sig.is_exported,
378 },
379 );
380 }
381
382 scanned += 1;
383 }
384
385 build_edges(&mut index);
386
387 if let Err(e) = index.save() {
388 tracing::warn!("could not save graph index: {e}");
389 }
390
391 tracing::warn!(
392 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
393 index.file_count(),
394 scanned,
395 reused,
396 index.symbol_count(),
397 index.edge_count()
398 );
399
400 index
401}
402
403fn build_edges(index: &mut ProjectIndex) {
404 index.edges.clear();
405
406 let root = normalize_project_root(&index.project_root);
407 let root_path = Path::new(&root);
408
409 let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
410 file_paths.sort();
411
412 let resolver_ctx = import_resolver::ResolverContext::new(root_path, file_paths.clone());
413
414 for rel_path in &file_paths {
415 let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
416 let Ok(content) = std::fs::read_to_string(&abs_path) else {
417 continue;
418 };
419
420 let ext = Path::new(rel_path)
421 .extension()
422 .and_then(|e| e.to_str())
423 .unwrap_or("");
424
425 let resolve_ext = match ext {
426 "vue" | "svelte" => "ts",
427 _ => ext,
428 };
429
430 let imports = crate::core::deep_queries::analyze(&content, resolve_ext).imports;
431 if imports.is_empty() {
432 continue;
433 }
434
435 let resolved =
436 import_resolver::resolve_imports(&imports, rel_path, resolve_ext, &resolver_ctx);
437 for r in resolved {
438 if r.is_external {
439 continue;
440 }
441 if let Some(to) = r.resolved_path {
442 index.edges.push(IndexEdge {
443 from: rel_path.clone(),
444 to,
445 kind: "import".to_string(),
446 });
447 }
448 }
449 }
450
451 index.edges.sort_by(|a, b| {
452 a.from
453 .cmp(&b.from)
454 .then_with(|| a.to.cmp(&b.to))
455 .then_with(|| a.kind.cmp(&b.kind))
456 });
457 index
458 .edges
459 .dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
460}
461
462fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
463 let lines: Vec<&str> = content.lines().collect();
464 let mut start = 0;
465
466 for (i, line) in lines.iter().enumerate() {
467 if line.contains(&sig.name) {
468 let trimmed = line.trim();
469 let is_def = trimmed.starts_with("fn ")
470 || trimmed.starts_with("pub fn ")
471 || trimmed.starts_with("pub(crate) fn ")
472 || trimmed.starts_with("async fn ")
473 || trimmed.starts_with("pub async fn ")
474 || trimmed.starts_with("struct ")
475 || trimmed.starts_with("pub struct ")
476 || trimmed.starts_with("enum ")
477 || trimmed.starts_with("pub enum ")
478 || trimmed.starts_with("trait ")
479 || trimmed.starts_with("pub trait ")
480 || trimmed.starts_with("impl ")
481 || trimmed.starts_with("class ")
482 || trimmed.starts_with("export class ")
483 || trimmed.starts_with("export function ")
484 || trimmed.starts_with("export async function ")
485 || trimmed.starts_with("function ")
486 || trimmed.starts_with("async function ")
487 || trimmed.starts_with("def ")
488 || trimmed.starts_with("async def ")
489 || trimmed.starts_with("func ")
490 || trimmed.starts_with("interface ")
491 || trimmed.starts_with("export interface ")
492 || trimmed.starts_with("type ")
493 || trimmed.starts_with("export type ")
494 || trimmed.starts_with("const ")
495 || trimmed.starts_with("export const ")
496 || trimmed.starts_with("fun ")
497 || trimmed.starts_with("private fun ")
498 || trimmed.starts_with("public fun ")
499 || trimmed.starts_with("internal fun ")
500 || trimmed.starts_with("class ")
501 || trimmed.starts_with("data class ")
502 || trimmed.starts_with("sealed class ")
503 || trimmed.starts_with("sealed interface ")
504 || trimmed.starts_with("enum class ")
505 || trimmed.starts_with("object ")
506 || trimmed.starts_with("private object ")
507 || trimmed.starts_with("interface ")
508 || trimmed.starts_with("typealias ")
509 || trimmed.starts_with("private typealias ");
510 if is_def {
511 start = i + 1;
512 break;
513 }
514 }
515 }
516
517 if start == 0 {
518 return (1, lines.len().min(20));
519 }
520
521 let base_indent = lines
522 .get(start - 1)
523 .map_or(0, |l| l.len() - l.trim_start().len());
524
525 let mut end = start;
526 let mut brace_depth: i32 = 0;
527 let mut found_open = false;
528
529 for (i, line) in lines.iter().enumerate().skip(start - 1) {
530 for ch in line.chars() {
531 if ch == '{' {
532 brace_depth += 1;
533 found_open = true;
534 } else if ch == '}' {
535 brace_depth -= 1;
536 }
537 }
538
539 end = i + 1;
540
541 if found_open && brace_depth <= 0 {
542 break;
543 }
544
545 if !found_open && i > start {
546 let indent = line.len() - line.trim_start().len();
547 if indent <= base_indent && !line.trim().is_empty() && i > start {
548 end = i;
549 break;
550 }
551 }
552
553 if end - start > 200 {
554 break;
555 }
556 }
557
558 (start, end)
559}
560
561fn extract_summary(content: &str) -> String {
562 for line in content.lines().take(20) {
563 let trimmed = line.trim();
564 if trimmed.is_empty()
565 || trimmed.starts_with("//")
566 || trimmed.starts_with('#')
567 || trimmed.starts_with("/*")
568 || trimmed.starts_with('*')
569 || trimmed.starts_with("use ")
570 || trimmed.starts_with("import ")
571 || trimmed.starts_with("from ")
572 || trimmed.starts_with("require(")
573 || trimmed.starts_with("package ")
574 {
575 continue;
576 }
577 return trimmed.chars().take(120).collect();
578 }
579 String::new()
580}
581
582fn compute_hash(content: &str) -> String {
583 use std::collections::hash_map::DefaultHasher;
584 use std::hash::{Hash, Hasher};
585
586 let mut hasher = DefaultHasher::new();
587 content.hash(&mut hasher);
588 format!("{:016x}", hasher.finish())
589}
590
591fn short_hash(input: &str) -> String {
592 use std::collections::hash_map::DefaultHasher;
593 use std::hash::{Hash, Hasher};
594
595 let mut hasher = DefaultHasher::new();
596 input.hash(&mut hasher);
597 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
598}
599
600fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
601 std::fs::create_dir_all(dst)?;
602 for entry in std::fs::read_dir(src)?.flatten() {
603 let from = entry.path();
604 let to = dst.join(entry.file_name());
605 if from.is_dir() {
606 copy_dir_fallible(&from, &to)?;
607 } else {
608 std::fs::copy(&from, &to)?;
609 }
610 }
611 Ok(())
612}
613
614fn normalize_absolute_path(path: &str) -> String {
615 if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
616 return canon.to_string_lossy().to_string();
617 }
618
619 let mut normalized = path.to_string();
620 while normalized.ends_with("\\.") || normalized.ends_with("/.") {
621 normalized.truncate(normalized.len() - 2);
622 }
623 while normalized.len() > 1
624 && (normalized.ends_with('\\') || normalized.ends_with('/'))
625 && !normalized.ends_with(":\\")
626 && !normalized.ends_with(":/")
627 && normalized != "\\"
628 && normalized != "/"
629 {
630 normalized.pop();
631 }
632 normalized
633}
634
635pub fn normalize_project_root(path: &str) -> String {
636 normalize_absolute_path(path)
637}
638
639pub fn graph_match_key(path: &str) -> String {
640 let stripped =
641 crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
642 stripped.trim_start_matches('/').to_string()
643}
644
645pub fn graph_relative_key(path: &str, root: &str) -> String {
646 let root_norm = normalize_project_root(root);
647 let path_norm = normalize_absolute_path(path);
648 let root_path = Path::new(&root_norm);
649 let path_path = Path::new(&path_norm);
650
651 if let Ok(rel) = path_path.strip_prefix(root_path) {
652 let rel = rel.to_string_lossy().to_string();
653 return rel.trim_start_matches(['/', '\\']).to_string();
654 }
655
656 path.trim_start_matches(['/', '\\'])
657 .replace('/', std::path::MAIN_SEPARATOR_STR)
658}
659
660fn make_relative(path: &str, root: &str) -> String {
661 graph_relative_key(path, root)
662}
663
664fn is_indexable_ext(ext: &str) -> bool {
665 crate::core::language_capabilities::is_indexable_ext(ext)
666}
667
668#[cfg(test)]
669fn kotlin_package_name(content: &str) -> Option<String> {
670 content.lines().map(str::trim).find_map(|line| {
671 line.strip_prefix("package ")
672 .map(|rest| rest.trim().trim_end_matches(';').to_string())
673 })
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use tempfile::tempdir;
680
681 #[test]
682 fn test_short_hash_deterministic() {
683 let h1 = short_hash("/Users/test/project");
684 let h2 = short_hash("/Users/test/project");
685 assert_eq!(h1, h2);
686 assert_eq!(h1.len(), 8);
687 }
688
689 #[test]
690 fn test_make_relative() {
691 assert_eq!(
692 make_relative("/foo/bar/src/main.rs", "/foo/bar"),
693 graph_relative_key("/foo/bar/src/main.rs", "/foo/bar")
694 );
695 assert_eq!(
696 make_relative("src/main.rs", "/foo/bar"),
697 graph_relative_key("src/main.rs", "/foo/bar")
698 );
699 assert_eq!(
700 make_relative("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo"),
701 graph_relative_key("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo")
702 );
703 assert_eq!(
704 make_relative("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo"),
705 graph_relative_key("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo")
706 );
707 }
708
709 #[test]
710 fn test_normalize_project_root() {
711 assert_eq!(normalize_project_root("C:\\repo\\"), "C:\\repo");
712 assert_eq!(normalize_project_root("C:\\repo\\."), "C:\\repo");
713 assert_eq!(normalize_project_root("//?/C:/repo/"), "//?/C:/repo");
714 }
715
716 #[test]
717 fn test_graph_match_key_normalizes_windows_forms() {
718 assert_eq!(
719 graph_match_key(r"C:\repo\src\main.rs"),
720 "C:/repo/src/main.rs"
721 );
722 assert_eq!(
723 graph_match_key(r"\\?\C:\repo\src\main.rs"),
724 "C:/repo/src/main.rs"
725 );
726 assert_eq!(graph_match_key(r"\src\main.rs"), "src/main.rs");
727 }
728
729 #[test]
730 fn test_extract_summary() {
731 let content = "// comment\nuse std::io;\n\npub fn main() {\n println!(\"hello\");\n}";
732 let summary = extract_summary(content);
733 assert_eq!(summary, "pub fn main() {");
734 }
735
736 #[test]
737 fn test_compute_hash_deterministic() {
738 let h1 = compute_hash("hello world");
739 let h2 = compute_hash("hello world");
740 assert_eq!(h1, h2);
741 assert_ne!(h1, compute_hash("hello world!"));
742 }
743
744 #[test]
745 fn test_project_index_new() {
746 let idx = ProjectIndex::new("/test");
747 assert_eq!(idx.version, INDEX_VERSION);
748 assert_eq!(idx.project_root, "/test");
749 assert!(idx.files.is_empty());
750 }
751
752 fn fe(path: &str, content: &str, language: &str) -> FileEntry {
753 FileEntry {
754 path: path.to_string(),
755 hash: compute_hash(content),
756 language: language.to_string(),
757 line_count: content.lines().count(),
758 token_count: crate::core::tokens::count_tokens(content),
759 exports: Vec::new(),
760 summary: extract_summary(content),
761 }
762 }
763
764 #[test]
765 fn test_index_looks_stale_when_any_file_missing() {
766 let td = tempdir().expect("tempdir");
767 let root = td.path();
768 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
769
770 let root_s = normalize_project_root(&root.to_string_lossy());
771 let mut idx = ProjectIndex::new(&root_s);
772 idx.files
773 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
774 idx.files.insert(
775 "missing.rs".to_string(),
776 fe("missing.rs", "pub fn m() {}\n", "rs"),
777 );
778
779 assert!(index_looks_stale(&idx, &root_s));
780 }
781
782 #[test]
783 fn test_index_looks_fresh_when_all_files_exist() {
784 let td = tempdir().expect("tempdir");
785 let root = td.path();
786 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
787
788 let root_s = normalize_project_root(&root.to_string_lossy());
789 let mut idx = ProjectIndex::new(&root_s);
790 idx.files
791 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
792
793 assert!(!index_looks_stale(&idx, &root_s));
794 }
795
796 #[test]
797 fn test_reverse_deps() {
798 let mut idx = ProjectIndex::new("/test");
799 idx.edges.push(IndexEdge {
800 from: "a.rs".to_string(),
801 to: "b.rs".to_string(),
802 kind: "import".to_string(),
803 });
804 idx.edges.push(IndexEdge {
805 from: "c.rs".to_string(),
806 to: "b.rs".to_string(),
807 kind: "import".to_string(),
808 });
809
810 let deps = idx.get_reverse_deps("b.rs", 1);
811 assert_eq!(deps.len(), 2);
812 assert!(deps.contains(&"a.rs".to_string()));
813 assert!(deps.contains(&"c.rs".to_string()));
814 }
815
816 #[test]
817 fn test_find_symbol_range_kotlin_function() {
818 let content = r#"
819package com.example
820
821class UserService {
822 fun greet(name: String): String {
823 return "hi $name"
824 }
825}
826"#;
827 let sig = signatures::Signature {
828 kind: "method",
829 name: "greet".to_string(),
830 params: "name:String".to_string(),
831 return_type: "String".to_string(),
832 is_async: false,
833 is_exported: true,
834 indent: 2,
835 ..signatures::Signature::no_span()
836 };
837 let (start, end) = find_symbol_range(content, &sig);
838 assert_eq!(start, 5);
839 assert!(end >= start);
840 }
841
842 #[test]
843 fn test_signature_spans_override_fallback_range() {
844 let sig = signatures::Signature {
845 kind: "method",
846 name: "release".to_string(),
847 params: "id:String".to_string(),
848 return_type: "Boolean".to_string(),
849 is_async: true,
850 is_exported: true,
851 indent: 2,
852 start_line: Some(42),
853 end_line: Some(43),
854 };
855
856 let (start, end) = sig
857 .start_line
858 .zip(sig.end_line)
859 .unwrap_or_else(|| find_symbol_range("ignored", &sig));
860 assert_eq!((start, end), (42, 43));
861 }
862
863 #[test]
864 fn test_parse_stale_index_version() {
865 let json = format!(
866 r#"{{"version":{},"project_root":"/test","last_scan":"now","files":{{}},"edges":[],"symbols":{{}}}}"#,
867 INDEX_VERSION - 1
868 );
869 let parsed: ProjectIndex = serde_json::from_str(&json).unwrap();
870 assert_ne!(parsed.version, INDEX_VERSION);
871 }
872
873 #[test]
874 fn test_kotlin_package_name() {
875 let content = "package com.example.feature\n\nclass UserService";
876 assert_eq!(
877 kotlin_package_name(content).as_deref(),
878 Some("com.example.feature")
879 );
880 }
881}