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