Skip to main content

lean_ctx/core/
graph_index.rs

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(&current) {
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(&current) {
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
153/// Load the best available graph index, trying multiple root path variants.
154/// If no valid index exists, automatically scans the project to build one.
155/// This is the primary entry point — ensures zero-config usage.
156pub fn load_or_build(project_root: &str) -> ProjectIndex {
157    // Prefer stable absolute roots. Using "." as a cache key is fragile because
158    // it depends on the process cwd and can accidentally load the wrong project.
159    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    // Try the absolute/root-normalized path first.
169    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    // Legacy: older builds may have cached the index under ".". Only accept it if it
180    // actually refers to the current cwd project, then migrate it to `root_abs`.
181    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    // Try absolute cwd
197    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    // No existing index found anywhere — auto-build
215    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}