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