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 = 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}