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 = cfg.graph_index_max_files as usize;
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 tracing::warn!(
317 "Graph index capped at {} files. Increase graph_index_max_files in config.toml for full coverage.",
318 max_files
319 );
320 break;
321 }
322
323 let Ok(content) = std::fs::read_to_string(&file_path) else {
324 continue;
325 };
326
327 let hash = compute_hash(&content);
328 let rel_path = make_relative(&file_path, &project_root);
329
330 if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
331 if *old_hash == hash {
332 if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
333 index.files.insert(rel_path.clone(), old_entry.clone());
334 for (key, sym) in old_syms {
335 index.symbols.insert(key.clone(), sym.clone());
336 }
337 reused += 1;
338 continue;
339 }
340 }
341 }
342
343 let sigs = signatures::extract_signatures(&content, ext);
344 let line_count = content.lines().count();
345 let token_count = crate::core::tokens::count_tokens(&content);
346 let summary = extract_summary(&content);
347
348 let exports: Vec<String> = sigs
349 .iter()
350 .filter(|s| s.is_exported)
351 .map(|s| s.name.clone())
352 .collect();
353
354 index.files.insert(
355 rel_path.clone(),
356 FileEntry {
357 path: rel_path.clone(),
358 hash,
359 language: ext.to_string(),
360 line_count,
361 token_count,
362 exports,
363 summary,
364 },
365 );
366
367 for sig in &sigs {
368 let (start, end) = sig
369 .start_line
370 .zip(sig.end_line)
371 .unwrap_or_else(|| find_symbol_range(&content, sig));
372 let key = format!("{}::{}", rel_path, sig.name);
373 index.symbols.insert(
374 key,
375 SymbolEntry {
376 file: rel_path.clone(),
377 name: sig.name.clone(),
378 kind: sig.kind.to_string(),
379 start_line: start,
380 end_line: end,
381 is_exported: sig.is_exported,
382 },
383 );
384 }
385
386 scanned += 1;
387 }
388
389 build_edges(&mut index);
390
391 if let Err(e) = index.save() {
392 tracing::warn!("could not save graph index: {e}");
393 }
394
395 tracing::warn!(
396 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
397 index.file_count(),
398 scanned,
399 reused,
400 index.symbol_count(),
401 index.edge_count()
402 );
403
404 index
405}
406
407fn build_edges(index: &mut ProjectIndex) {
408 index.edges.clear();
409
410 let root = normalize_project_root(&index.project_root);
411 let root_path = Path::new(&root);
412
413 let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
414 file_paths.sort();
415
416 let resolver_ctx = import_resolver::ResolverContext::new(root_path, file_paths.clone());
417
418 for rel_path in &file_paths {
419 let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
420 let Ok(content) = std::fs::read_to_string(&abs_path) else {
421 continue;
422 };
423
424 let ext = Path::new(rel_path)
425 .extension()
426 .and_then(|e| e.to_str())
427 .unwrap_or("");
428
429 let resolve_ext = match ext {
430 "vue" | "svelte" => "ts",
431 _ => ext,
432 };
433
434 let imports = crate::core::deep_queries::analyze(&content, resolve_ext).imports;
435 if imports.is_empty() {
436 continue;
437 }
438
439 let resolved =
440 import_resolver::resolve_imports(&imports, rel_path, resolve_ext, &resolver_ctx);
441 for r in resolved {
442 if r.is_external {
443 continue;
444 }
445 if let Some(to) = r.resolved_path {
446 index.edges.push(IndexEdge {
447 from: rel_path.clone(),
448 to,
449 kind: "import".to_string(),
450 });
451 }
452 }
453 }
454
455 index.edges.sort_by(|a, b| {
456 a.from
457 .cmp(&b.from)
458 .then_with(|| a.to.cmp(&b.to))
459 .then_with(|| a.kind.cmp(&b.kind))
460 });
461 index
462 .edges
463 .dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
464}
465
466fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
467 let lines: Vec<&str> = content.lines().collect();
468 let mut start = 0;
469
470 for (i, line) in lines.iter().enumerate() {
471 if line.contains(&sig.name) {
472 let trimmed = line.trim();
473 let is_def = trimmed.starts_with("fn ")
474 || trimmed.starts_with("pub fn ")
475 || trimmed.starts_with("pub(crate) fn ")
476 || trimmed.starts_with("async fn ")
477 || trimmed.starts_with("pub async fn ")
478 || trimmed.starts_with("struct ")
479 || trimmed.starts_with("pub struct ")
480 || trimmed.starts_with("enum ")
481 || trimmed.starts_with("pub enum ")
482 || trimmed.starts_with("trait ")
483 || trimmed.starts_with("pub trait ")
484 || trimmed.starts_with("impl ")
485 || trimmed.starts_with("class ")
486 || trimmed.starts_with("export class ")
487 || trimmed.starts_with("export function ")
488 || trimmed.starts_with("export async function ")
489 || trimmed.starts_with("function ")
490 || trimmed.starts_with("async function ")
491 || trimmed.starts_with("def ")
492 || trimmed.starts_with("async def ")
493 || trimmed.starts_with("func ")
494 || trimmed.starts_with("interface ")
495 || trimmed.starts_with("export interface ")
496 || trimmed.starts_with("type ")
497 || trimmed.starts_with("export type ")
498 || trimmed.starts_with("const ")
499 || trimmed.starts_with("export const ")
500 || trimmed.starts_with("fun ")
501 || trimmed.starts_with("private fun ")
502 || trimmed.starts_with("public fun ")
503 || trimmed.starts_with("internal fun ")
504 || trimmed.starts_with("class ")
505 || trimmed.starts_with("data class ")
506 || trimmed.starts_with("sealed class ")
507 || trimmed.starts_with("sealed interface ")
508 || trimmed.starts_with("enum class ")
509 || trimmed.starts_with("object ")
510 || trimmed.starts_with("private object ")
511 || trimmed.starts_with("interface ")
512 || trimmed.starts_with("typealias ")
513 || trimmed.starts_with("private typealias ");
514 if is_def {
515 start = i + 1;
516 break;
517 }
518 }
519 }
520
521 if start == 0 {
522 return (1, lines.len().min(20));
523 }
524
525 let base_indent = lines
526 .get(start - 1)
527 .map_or(0, |l| l.len() - l.trim_start().len());
528
529 let mut end = start;
530 let mut brace_depth: i32 = 0;
531 let mut found_open = false;
532
533 for (i, line) in lines.iter().enumerate().skip(start - 1) {
534 for ch in line.chars() {
535 if ch == '{' {
536 brace_depth += 1;
537 found_open = true;
538 } else if ch == '}' {
539 brace_depth -= 1;
540 }
541 }
542
543 end = i + 1;
544
545 if found_open && brace_depth <= 0 {
546 break;
547 }
548
549 if !found_open && i > start {
550 let indent = line.len() - line.trim_start().len();
551 if indent <= base_indent && !line.trim().is_empty() && i > start {
552 end = i;
553 break;
554 }
555 }
556
557 if end - start > 200 {
558 break;
559 }
560 }
561
562 (start, end)
563}
564
565fn extract_summary(content: &str) -> String {
566 for line in content.lines().take(20) {
567 let trimmed = line.trim();
568 if trimmed.is_empty()
569 || trimmed.starts_with("//")
570 || trimmed.starts_with('#')
571 || trimmed.starts_with("/*")
572 || trimmed.starts_with('*')
573 || trimmed.starts_with("use ")
574 || trimmed.starts_with("import ")
575 || trimmed.starts_with("from ")
576 || trimmed.starts_with("require(")
577 || trimmed.starts_with("package ")
578 {
579 continue;
580 }
581 return trimmed.chars().take(120).collect();
582 }
583 String::new()
584}
585
586fn compute_hash(content: &str) -> String {
587 use std::collections::hash_map::DefaultHasher;
588 use std::hash::{Hash, Hasher};
589
590 let mut hasher = DefaultHasher::new();
591 content.hash(&mut hasher);
592 format!("{:016x}", hasher.finish())
593}
594
595fn short_hash(input: &str) -> String {
596 use std::collections::hash_map::DefaultHasher;
597 use std::hash::{Hash, Hasher};
598
599 let mut hasher = DefaultHasher::new();
600 input.hash(&mut hasher);
601 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
602}
603
604fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
605 std::fs::create_dir_all(dst)?;
606 for entry in std::fs::read_dir(src)?.flatten() {
607 let from = entry.path();
608 let to = dst.join(entry.file_name());
609 if from.is_dir() {
610 copy_dir_fallible(&from, &to)?;
611 } else {
612 std::fs::copy(&from, &to)?;
613 }
614 }
615 Ok(())
616}
617
618fn normalize_absolute_path(path: &str) -> String {
619 if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
620 return canon.to_string_lossy().to_string();
621 }
622
623 let mut normalized = path.to_string();
624 while normalized.ends_with("\\.") || normalized.ends_with("/.") {
625 normalized.truncate(normalized.len() - 2);
626 }
627 while normalized.len() > 1
628 && (normalized.ends_with('\\') || normalized.ends_with('/'))
629 && !normalized.ends_with(":\\")
630 && !normalized.ends_with(":/")
631 && normalized != "\\"
632 && normalized != "/"
633 {
634 normalized.pop();
635 }
636 normalized
637}
638
639pub fn normalize_project_root(path: &str) -> String {
640 normalize_absolute_path(path)
641}
642
643pub fn graph_match_key(path: &str) -> String {
644 let stripped =
645 crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
646 stripped.trim_start_matches('/').to_string()
647}
648
649pub fn graph_relative_key(path: &str, root: &str) -> String {
650 let root_norm = normalize_project_root(root);
651 let path_norm = normalize_absolute_path(path);
652 let root_path = Path::new(&root_norm);
653 let path_path = Path::new(&path_norm);
654
655 if let Ok(rel) = path_path.strip_prefix(root_path) {
656 let rel = rel.to_string_lossy().to_string();
657 return rel.trim_start_matches(['/', '\\']).to_string();
658 }
659
660 path.trim_start_matches(['/', '\\'])
661 .replace('/', std::path::MAIN_SEPARATOR_STR)
662}
663
664fn make_relative(path: &str, root: &str) -> String {
665 graph_relative_key(path, root)
666}
667
668fn is_indexable_ext(ext: &str) -> bool {
669 crate::core::language_capabilities::is_indexable_ext(ext)
670}
671
672#[cfg(test)]
673fn kotlin_package_name(content: &str) -> Option<String> {
674 content.lines().map(str::trim).find_map(|line| {
675 line.strip_prefix("package ")
676 .map(|rest| rest.trim().trim_end_matches(';').to_string())
677 })
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use tempfile::tempdir;
684
685 #[test]
686 fn test_short_hash_deterministic() {
687 let h1 = short_hash("/Users/test/project");
688 let h2 = short_hash("/Users/test/project");
689 assert_eq!(h1, h2);
690 assert_eq!(h1.len(), 8);
691 }
692
693 #[test]
694 fn test_make_relative() {
695 assert_eq!(
696 make_relative("/foo/bar/src/main.rs", "/foo/bar"),
697 graph_relative_key("/foo/bar/src/main.rs", "/foo/bar")
698 );
699 assert_eq!(
700 make_relative("src/main.rs", "/foo/bar"),
701 graph_relative_key("src/main.rs", "/foo/bar")
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 assert_eq!(
708 make_relative("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo"),
709 graph_relative_key("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo")
710 );
711 }
712
713 #[test]
714 fn test_normalize_project_root() {
715 assert_eq!(normalize_project_root("C:\\repo\\"), "C:\\repo");
716 assert_eq!(normalize_project_root("C:\\repo\\."), "C:\\repo");
717 assert_eq!(normalize_project_root("//?/C:/repo/"), "//?/C:/repo");
718 }
719
720 #[test]
721 fn test_graph_match_key_normalizes_windows_forms() {
722 assert_eq!(
723 graph_match_key(r"C:\repo\src\main.rs"),
724 "C:/repo/src/main.rs"
725 );
726 assert_eq!(
727 graph_match_key(r"\\?\C:\repo\src\main.rs"),
728 "C:/repo/src/main.rs"
729 );
730 assert_eq!(graph_match_key(r"\src\main.rs"), "src/main.rs");
731 }
732
733 #[test]
734 fn test_extract_summary() {
735 let content = "// comment\nuse std::io;\n\npub fn main() {\n println!(\"hello\");\n}";
736 let summary = extract_summary(content);
737 assert_eq!(summary, "pub fn main() {");
738 }
739
740 #[test]
741 fn test_compute_hash_deterministic() {
742 let h1 = compute_hash("hello world");
743 let h2 = compute_hash("hello world");
744 assert_eq!(h1, h2);
745 assert_ne!(h1, compute_hash("hello world!"));
746 }
747
748 #[test]
749 fn test_project_index_new() {
750 let idx = ProjectIndex::new("/test");
751 assert_eq!(idx.version, INDEX_VERSION);
752 assert_eq!(idx.project_root, "/test");
753 assert!(idx.files.is_empty());
754 }
755
756 fn fe(path: &str, content: &str, language: &str) -> FileEntry {
757 FileEntry {
758 path: path.to_string(),
759 hash: compute_hash(content),
760 language: language.to_string(),
761 line_count: content.lines().count(),
762 token_count: crate::core::tokens::count_tokens(content),
763 exports: Vec::new(),
764 summary: extract_summary(content),
765 }
766 }
767
768 #[test]
769 fn test_index_looks_stale_when_any_file_missing() {
770 let td = tempdir().expect("tempdir");
771 let root = td.path();
772 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
773
774 let root_s = normalize_project_root(&root.to_string_lossy());
775 let mut idx = ProjectIndex::new(&root_s);
776 idx.files
777 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
778 idx.files.insert(
779 "missing.rs".to_string(),
780 fe("missing.rs", "pub fn m() {}\n", "rs"),
781 );
782
783 assert!(index_looks_stale(&idx, &root_s));
784 }
785
786 #[test]
787 fn test_index_looks_fresh_when_all_files_exist() {
788 let td = tempdir().expect("tempdir");
789 let root = td.path();
790 std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
791
792 let root_s = normalize_project_root(&root.to_string_lossy());
793 let mut idx = ProjectIndex::new(&root_s);
794 idx.files
795 .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
796
797 assert!(!index_looks_stale(&idx, &root_s));
798 }
799
800 #[test]
801 fn test_reverse_deps() {
802 let mut idx = ProjectIndex::new("/test");
803 idx.edges.push(IndexEdge {
804 from: "a.rs".to_string(),
805 to: "b.rs".to_string(),
806 kind: "import".to_string(),
807 });
808 idx.edges.push(IndexEdge {
809 from: "c.rs".to_string(),
810 to: "b.rs".to_string(),
811 kind: "import".to_string(),
812 });
813
814 let deps = idx.get_reverse_deps("b.rs", 1);
815 assert_eq!(deps.len(), 2);
816 assert!(deps.contains(&"a.rs".to_string()));
817 assert!(deps.contains(&"c.rs".to_string()));
818 }
819
820 #[test]
821 fn test_find_symbol_range_kotlin_function() {
822 let content = r#"
823package com.example
824
825class UserService {
826 fun greet(name: String): String {
827 return "hi $name"
828 }
829}
830"#;
831 let sig = signatures::Signature {
832 kind: "method",
833 name: "greet".to_string(),
834 params: "name:String".to_string(),
835 return_type: "String".to_string(),
836 is_async: false,
837 is_exported: true,
838 indent: 2,
839 ..signatures::Signature::no_span()
840 };
841 let (start, end) = find_symbol_range(content, &sig);
842 assert_eq!(start, 5);
843 assert!(end >= start);
844 }
845
846 #[test]
847 fn test_signature_spans_override_fallback_range() {
848 let sig = signatures::Signature {
849 kind: "method",
850 name: "release".to_string(),
851 params: "id:String".to_string(),
852 return_type: "Boolean".to_string(),
853 is_async: true,
854 is_exported: true,
855 indent: 2,
856 start_line: Some(42),
857 end_line: Some(43),
858 };
859
860 let (start, end) = sig
861 .start_line
862 .zip(sig.end_line)
863 .unwrap_or_else(|| find_symbol_range("ignored", &sig));
864 assert_eq!((start, end), (42, 43));
865 }
866
867 #[test]
868 fn test_parse_stale_index_version() {
869 let json = format!(
870 r#"{{"version":{},"project_root":"/test","last_scan":"now","files":{{}},"edges":[],"symbols":{{}}}}"#,
871 INDEX_VERSION - 1
872 );
873 let parsed: ProjectIndex = serde_json::from_str(&json).unwrap();
874 assert_ne!(parsed.version, INDEX_VERSION);
875 }
876
877 #[test]
878 fn test_kotlin_package_name() {
879 let content = "package com.example.feature\n\nclass UserService";
880 assert_eq!(
881 kotlin_package_name(content).as_deref(),
882 Some("com.example.feature")
883 );
884 }
885}