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
11pub fn is_safe_scan_root_public(path: &str) -> bool {
12    is_safe_scan_root(path)
13}
14
15fn is_filesystem_root(path: &str) -> bool {
16    let p = Path::new(path);
17    p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
18}
19
20fn is_safe_scan_root(path: &str) -> bool {
21    let normalized = normalize_project_root(path);
22    let p = Path::new(&normalized);
23
24    if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
25        tracing::warn!("[graph_index: refusing to scan filesystem root]");
26        return false;
27    }
28
29    if let Some(home) = dirs::home_dir() {
30        let home_norm = normalize_project_root(&home.to_string_lossy());
31        if normalized == home_norm {
32            tracing::warn!(
33                "[graph_index: refusing to scan home directory {normalized} — \
34                 set LEAN_CTX_PROJECT_ROOT or run from inside a project]"
35            );
36            return false;
37        }
38    }
39
40    let breadth_markers = [
41        ".git",
42        "Cargo.toml",
43        "package.json",
44        "go.mod",
45        "pyproject.toml",
46        "setup.py",
47        "Makefile",
48        "CMakeLists.txt",
49        "pnpm-workspace.yaml",
50        ".projectile",
51        "BUILD.bazel",
52        "go.work",
53    ];
54
55    if !breadth_markers.iter().any(|m| p.join(m).exists()) {
56        let child_count = std::fs::read_dir(p).map_or(0, |rd| {
57            rd.filter_map(Result::ok)
58                .filter(|e| e.path().is_dir())
59                .count()
60        });
61        if child_count > 50 {
62            tracing::warn!(
63                "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
64                 skipping scan to avoid indexing broad directories]"
65            );
66            return false;
67        }
68    }
69
70    true
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ProjectIndex {
75    pub version: u32,
76    pub project_root: String,
77    pub last_scan: String,
78    pub files: HashMap<String, FileEntry>,
79    pub edges: Vec<IndexEdge>,
80    pub symbols: HashMap<String, SymbolEntry>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileEntry {
85    pub path: String,
86    pub hash: String,
87    pub language: String,
88    pub line_count: usize,
89    pub token_count: usize,
90    pub exports: Vec<String>,
91    pub summary: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SymbolEntry {
96    pub file: String,
97    pub name: String,
98    pub kind: String,
99    pub start_line: usize,
100    pub end_line: usize,
101    pub is_exported: bool,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct IndexEdge {
106    pub from: String,
107    pub to: String,
108    pub kind: String,
109}
110
111impl ProjectIndex {
112    pub fn new(project_root: &str) -> Self {
113        Self {
114            version: INDEX_VERSION,
115            project_root: normalize_project_root(project_root),
116            last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
117            files: HashMap::new(),
118            edges: Vec::new(),
119            symbols: HashMap::new(),
120        }
121    }
122
123    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
124        let normalized = normalize_project_root(project_root);
125        let hash = crate::core::project_hash::hash_project_root(&normalized);
126        crate::core::data_dir::lean_ctx_data_dir()
127            .ok()
128            .map(|d| d.join("graphs").join(hash))
129    }
130
131    pub fn load(project_root: &str) -> Option<Self> {
132        let dir = Self::index_dir(project_root)?;
133        let path = dir.join("index.json");
134
135        let content = std::fs::read_to_string(&path)
136            .or_else(|_| -> std::io::Result<String> {
137                let legacy_hash = short_hash(&normalize_project_root(project_root));
138                let legacy_dir = crate::core::data_dir::lean_ctx_data_dir()
139                    .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no data dir"))?
140                    .join("graphs")
141                    .join(legacy_hash);
142                let legacy_path = legacy_dir.join("index.json");
143                let data = std::fs::read_to_string(&legacy_path)?;
144                if let Err(e) = copy_dir_fallible(&legacy_dir, &dir) {
145                    tracing::debug!("graph index migration: {e}");
146                }
147                Ok(data)
148            })
149            .ok()?;
150        let index: Self = serde_json::from_str(&content).ok()?;
151        if index.version != INDEX_VERSION {
152            return None;
153        }
154        Some(index)
155    }
156
157    pub fn save(&self) -> Result<(), String> {
158        let dir = Self::index_dir(&self.project_root)
159            .ok_or_else(|| "Cannot determine data directory".to_string())?;
160        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
161        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
162        std::fs::write(dir.join("index.json"), json).map_err(|e| e.to_string())
163    }
164
165    pub fn file_count(&self) -> usize {
166        self.files.len()
167    }
168
169    pub fn symbol_count(&self) -> usize {
170        self.symbols.len()
171    }
172
173    pub fn edge_count(&self) -> usize {
174        self.edges.len()
175    }
176
177    pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
178        self.symbols.get(key)
179    }
180
181    pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
182        let mut result = Vec::new();
183        let mut visited = std::collections::HashSet::new();
184        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
185
186        while let Some((current, d)) = queue.pop() {
187            if d > depth || visited.contains(&current) {
188                continue;
189            }
190            visited.insert(current.clone());
191            if current != path {
192                result.push(current.clone());
193            }
194
195            for edge in &self.edges {
196                if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
197                    queue.push((edge.from.clone(), d + 1));
198                }
199            }
200        }
201        result
202    }
203
204    pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
205        let mut result = Vec::new();
206        let mut visited = std::collections::HashSet::new();
207        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
208
209        while let Some((current, d)) = queue.pop() {
210            if d > depth || visited.contains(&current) {
211                continue;
212            }
213            visited.insert(current.clone());
214            if current != path {
215                result.push(current.clone());
216            }
217
218            for edge in &self.edges {
219                if edge.from == current && !visited.contains(&edge.to) {
220                    queue.push((edge.to.clone(), d + 1));
221                }
222                if edge.to == current && !visited.contains(&edge.from) {
223                    queue.push((edge.from.clone(), d + 1));
224                }
225            }
226        }
227        result
228    }
229}
230
231/// Load the best available graph index, trying multiple root path variants.
232/// If no valid index exists, automatically scans the project to build one.
233/// This is the primary entry point — ensures zero-config usage.
234pub fn load_or_build(project_root: &str) -> ProjectIndex {
235    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
236        return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
237    }
238
239    // Prefer stable absolute roots. Using "." as a cache key is fragile because
240    // it depends on the process cwd and can accidentally load the wrong project.
241    let root_abs = if project_root.trim().is_empty() || project_root == "." {
242        std::env::current_dir().ok().map_or_else(
243            || ".".to_string(),
244            |p| normalize_project_root(&p.to_string_lossy()),
245        )
246    } else {
247        normalize_project_root(project_root)
248    };
249
250    if !is_safe_scan_root(&root_abs) {
251        return ProjectIndex::new(&root_abs);
252    }
253
254    // Try the absolute/root-normalized path first.
255    if let Some(idx) = ProjectIndex::load(&root_abs) {
256        if !idx.files.is_empty() {
257            if index_looks_stale(&idx, &root_abs) {
258                tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
259                return scan(&root_abs);
260            }
261            return idx;
262        }
263    }
264
265    // Legacy: older builds may have cached the index under ".". Only accept it if it
266    // actually refers to the current cwd project, then migrate it to `root_abs`.
267    if let Some(idx) = ProjectIndex::load(".") {
268        if !idx.files.is_empty() {
269            let mut migrated = idx;
270            migrated.project_root.clone_from(&root_abs);
271            let _ = migrated.save();
272            if index_looks_stale(&migrated, &root_abs) {
273                tracing::warn!(
274                    "[graph_index: stale legacy index detected for {root_abs}; rebuilding]"
275                );
276                return scan(&root_abs);
277            }
278            return migrated;
279        }
280    }
281
282    // Try absolute cwd
283    if let Ok(cwd) = std::env::current_dir() {
284        let cwd_str = normalize_project_root(&cwd.to_string_lossy());
285        if cwd_str != root_abs {
286            if let Some(idx) = ProjectIndex::load(&cwd_str) {
287                if !idx.files.is_empty() {
288                    if index_looks_stale(&idx, &cwd_str) {
289                        tracing::warn!(
290                            "[graph_index: stale index detected for {cwd_str}; rebuilding]"
291                        );
292                        return scan(&cwd_str);
293                    }
294                    return idx;
295                }
296            }
297        }
298    }
299
300    // No existing index found anywhere — auto-build
301    scan(&root_abs)
302}
303
304fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
305    if index.files.is_empty() {
306        return true;
307    }
308
309    let root_path = Path::new(root_abs);
310    for rel in index.files.keys() {
311        let rel = rel.trim_start_matches(['/', '\\']);
312        if rel.is_empty() {
313            continue;
314        }
315        let abs = root_path.join(rel);
316        if !abs.exists() {
317            return true;
318        }
319    }
320
321    false
322}
323
324pub fn scan(project_root: &str) -> ProjectIndex {
325    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
326        tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
327        return ProjectIndex::new(project_root);
328    }
329
330    let project_root = normalize_project_root(project_root);
331
332    if !is_safe_scan_root(&project_root) {
333        tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
334        return ProjectIndex::new(&project_root);
335    }
336
337    let lock_name = format!(
338        "graph-idx-{}",
339        &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
340    );
341    let _lock = crate::core::startup_guard::try_acquire_lock(
342        &lock_name,
343        std::time::Duration::from_millis(800),
344        std::time::Duration::from_mins(3),
345    );
346    if _lock.is_none() {
347        tracing::info!(
348            "[graph_index: another process is scanning {project_root} — returning cached or empty]"
349        );
350        return ProjectIndex::load(&project_root)
351            .unwrap_or_else(|| ProjectIndex::new(&project_root));
352    }
353
354    let existing = ProjectIndex::load(&project_root);
355    let mut index = ProjectIndex::new(&project_root);
356
357    let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
358        if let Some(ref prev) = existing {
359            prev.files
360                .iter()
361                .map(|(path, entry)| {
362                    let syms: Vec<(String, SymbolEntry)> = prev
363                        .symbols
364                        .iter()
365                        .filter(|(_, s)| s.file == *path)
366                        .map(|(k, v)| (k.clone(), v.clone()))
367                        .collect();
368                    (path.clone(), (entry.hash.clone(), syms))
369                })
370                .collect()
371        } else {
372            HashMap::new()
373        };
374
375    let walker = ignore::WalkBuilder::new(&project_root)
376        .hidden(true)
377        .git_ignore(true)
378        .git_global(true)
379        .git_exclude(true)
380        .max_depth(Some(10))
381        .build();
382
383    let cfg = crate::core::config::Config::load();
384    let extra_ignores: Vec<glob::Pattern> = cfg
385        .extra_ignore_patterns
386        .iter()
387        .filter_map(|p| glob::Pattern::new(p).ok())
388        .collect();
389
390    let mut scanned = 0usize;
391    let mut reused = 0usize;
392    let mut entries_visited = 0usize;
393    let max_files = cfg.graph_index_max_files as usize;
394    const MAX_ENTRIES_VISITED: usize = 50_000;
395    let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(2);
396
397    for entry in walker.filter_map(std::result::Result::ok) {
398        entries_visited += 1;
399        if entries_visited > MAX_ENTRIES_VISITED {
400            tracing::warn!(
401                "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
402                 runaway traversal. Indexed {} files so far.]",
403                index.files.len()
404            );
405            break;
406        }
407        if entries_visited.is_multiple_of(5000) {
408            if std::time::Instant::now() > scan_deadline {
409                tracing::warn!(
410                    "[graph_index: scan timeout (120s) after {entries_visited} entries — \
411                     saving partial index with {} files]",
412                    index.files.len()
413                );
414                break;
415            }
416            if let Some(ref g) = _lock {
417                g.touch();
418            }
419        }
420
421        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
422            continue;
423        }
424        let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
425        let ext = Path::new(&file_path)
426            .extension()
427            .and_then(|e| e.to_str())
428            .unwrap_or("");
429
430        if !is_indexable_ext(ext) {
431            continue;
432        }
433
434        let rel = make_relative(&file_path, &project_root);
435        if extra_ignores.iter().any(|p| p.matches(&rel)) {
436            continue;
437        }
438
439        if index.files.len() >= max_files {
440            tracing::warn!(
441                "Graph index capped at {} files. Increase graph_index_max_files in config.toml for full coverage.",
442                max_files
443            );
444            break;
445        }
446
447        let Ok(content) = std::fs::read_to_string(&file_path) else {
448            continue;
449        };
450
451        let hash = compute_hash(&content);
452        let rel_path = make_relative(&file_path, &project_root);
453
454        if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
455            if *old_hash == hash {
456                if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
457                    index.files.insert(rel_path.clone(), old_entry.clone());
458                    for (key, sym) in old_syms {
459                        index.symbols.insert(key.clone(), sym.clone());
460                    }
461                    reused += 1;
462                    continue;
463                }
464            }
465        }
466
467        let sigs = signatures::extract_signatures(&content, ext);
468        let line_count = content.lines().count();
469        let token_count = crate::core::tokens::count_tokens(&content);
470        let summary = extract_summary(&content);
471
472        let exports: Vec<String> = sigs
473            .iter()
474            .filter(|s| s.is_exported)
475            .map(|s| s.name.clone())
476            .collect();
477
478        index.files.insert(
479            rel_path.clone(),
480            FileEntry {
481                path: rel_path.clone(),
482                hash,
483                language: ext.to_string(),
484                line_count,
485                token_count,
486                exports,
487                summary,
488            },
489        );
490
491        for sig in &sigs {
492            let (start, end) = sig
493                .start_line
494                .zip(sig.end_line)
495                .unwrap_or_else(|| find_symbol_range(&content, sig));
496            let key = format!("{}::{}", rel_path, sig.name);
497            index.symbols.insert(
498                key,
499                SymbolEntry {
500                    file: rel_path.clone(),
501                    name: sig.name.clone(),
502                    kind: sig.kind.to_string(),
503                    start_line: start,
504                    end_line: end,
505                    is_exported: sig.is_exported,
506                },
507            );
508        }
509
510        scanned += 1;
511    }
512
513    build_edges(&mut index);
514
515    if let Err(e) = index.save() {
516        tracing::warn!("could not save graph index: {e}");
517    }
518
519    tracing::warn!(
520        "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
521        index.file_count(),
522        scanned,
523        reused,
524        index.symbol_count(),
525        index.edge_count()
526    );
527
528    index
529}
530
531fn build_edges(index: &mut ProjectIndex) {
532    index.edges.clear();
533
534    let root = normalize_project_root(&index.project_root);
535    let root_path = Path::new(&root);
536
537    let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
538    file_paths.sort();
539
540    let resolver_ctx = import_resolver::ResolverContext::new(root_path, file_paths.clone());
541
542    for rel_path in &file_paths {
543        let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
544        let Ok(content) = std::fs::read_to_string(&abs_path) else {
545            continue;
546        };
547
548        let ext = Path::new(rel_path)
549            .extension()
550            .and_then(|e| e.to_str())
551            .unwrap_or("");
552
553        let resolve_ext = match ext {
554            "vue" | "svelte" => "ts",
555            _ => ext,
556        };
557
558        let imports = crate::core::deep_queries::analyze(&content, resolve_ext).imports;
559        if imports.is_empty() {
560            continue;
561        }
562
563        let resolved =
564            import_resolver::resolve_imports(&imports, rel_path, resolve_ext, &resolver_ctx);
565        for r in resolved {
566            if r.is_external {
567                continue;
568            }
569            if let Some(to) = r.resolved_path {
570                index.edges.push(IndexEdge {
571                    from: rel_path.clone(),
572                    to,
573                    kind: "import".to_string(),
574                });
575            }
576        }
577    }
578
579    index.edges.sort_by(|a, b| {
580        a.from
581            .cmp(&b.from)
582            .then_with(|| a.to.cmp(&b.to))
583            .then_with(|| a.kind.cmp(&b.kind))
584    });
585    index
586        .edges
587        .dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
588}
589
590fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
591    let lines: Vec<&str> = content.lines().collect();
592    let mut start = 0;
593
594    for (i, line) in lines.iter().enumerate() {
595        if line.contains(&sig.name) {
596            let trimmed = line.trim();
597            let is_def = trimmed.starts_with("fn ")
598                || trimmed.starts_with("pub fn ")
599                || trimmed.starts_with("pub(crate) fn ")
600                || trimmed.starts_with("async fn ")
601                || trimmed.starts_with("pub async fn ")
602                || trimmed.starts_with("struct ")
603                || trimmed.starts_with("pub struct ")
604                || trimmed.starts_with("enum ")
605                || trimmed.starts_with("pub enum ")
606                || trimmed.starts_with("trait ")
607                || trimmed.starts_with("pub trait ")
608                || trimmed.starts_with("impl ")
609                || trimmed.starts_with("class ")
610                || trimmed.starts_with("export class ")
611                || trimmed.starts_with("export function ")
612                || trimmed.starts_with("export async function ")
613                || trimmed.starts_with("function ")
614                || trimmed.starts_with("async function ")
615                || trimmed.starts_with("def ")
616                || trimmed.starts_with("async def ")
617                || trimmed.starts_with("func ")
618                || trimmed.starts_with("interface ")
619                || trimmed.starts_with("export interface ")
620                || trimmed.starts_with("type ")
621                || trimmed.starts_with("export type ")
622                || trimmed.starts_with("const ")
623                || trimmed.starts_with("export const ")
624                || trimmed.starts_with("fun ")
625                || trimmed.starts_with("private fun ")
626                || trimmed.starts_with("public fun ")
627                || trimmed.starts_with("internal fun ")
628                || trimmed.starts_with("class ")
629                || trimmed.starts_with("data class ")
630                || trimmed.starts_with("sealed class ")
631                || trimmed.starts_with("sealed interface ")
632                || trimmed.starts_with("enum class ")
633                || trimmed.starts_with("object ")
634                || trimmed.starts_with("private object ")
635                || trimmed.starts_with("interface ")
636                || trimmed.starts_with("typealias ")
637                || trimmed.starts_with("private typealias ");
638            if is_def {
639                start = i + 1;
640                break;
641            }
642        }
643    }
644
645    if start == 0 {
646        return (1, lines.len().min(20));
647    }
648
649    let base_indent = lines
650        .get(start - 1)
651        .map_or(0, |l| l.len() - l.trim_start().len());
652
653    let mut end = start;
654    let mut brace_depth: i32 = 0;
655    let mut found_open = false;
656
657    for (i, line) in lines.iter().enumerate().skip(start - 1) {
658        for ch in line.chars() {
659            if ch == '{' {
660                brace_depth += 1;
661                found_open = true;
662            } else if ch == '}' {
663                brace_depth -= 1;
664            }
665        }
666
667        end = i + 1;
668
669        if found_open && brace_depth <= 0 {
670            break;
671        }
672
673        if !found_open && i > start {
674            let indent = line.len() - line.trim_start().len();
675            if indent <= base_indent && !line.trim().is_empty() && i > start {
676                end = i;
677                break;
678            }
679        }
680
681        if end - start > 200 {
682            break;
683        }
684    }
685
686    (start, end)
687}
688
689fn extract_summary(content: &str) -> String {
690    for line in content.lines().take(20) {
691        let trimmed = line.trim();
692        if trimmed.is_empty()
693            || trimmed.starts_with("//")
694            || trimmed.starts_with('#')
695            || trimmed.starts_with("/*")
696            || trimmed.starts_with('*')
697            || trimmed.starts_with("use ")
698            || trimmed.starts_with("import ")
699            || trimmed.starts_with("from ")
700            || trimmed.starts_with("require(")
701            || trimmed.starts_with("package ")
702        {
703            continue;
704        }
705        return trimmed.chars().take(120).collect();
706    }
707    String::new()
708}
709
710fn compute_hash(content: &str) -> String {
711    use std::collections::hash_map::DefaultHasher;
712    use std::hash::{Hash, Hasher};
713
714    let mut hasher = DefaultHasher::new();
715    content.hash(&mut hasher);
716    format!("{:016x}", hasher.finish())
717}
718
719fn short_hash(input: &str) -> String {
720    use std::collections::hash_map::DefaultHasher;
721    use std::hash::{Hash, Hasher};
722
723    let mut hasher = DefaultHasher::new();
724    input.hash(&mut hasher);
725    format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
726}
727
728fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
729    std::fs::create_dir_all(dst)?;
730    for entry in std::fs::read_dir(src)?.flatten() {
731        let from = entry.path();
732        let to = dst.join(entry.file_name());
733        if from.is_dir() {
734            copy_dir_fallible(&from, &to)?;
735        } else {
736            std::fs::copy(&from, &to)?;
737        }
738    }
739    Ok(())
740}
741
742fn normalize_absolute_path(path: &str) -> String {
743    if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
744        return canon.to_string_lossy().to_string();
745    }
746
747    let mut normalized = path.to_string();
748    while normalized.ends_with("\\.") || normalized.ends_with("/.") {
749        normalized.truncate(normalized.len() - 2);
750    }
751    while normalized.len() > 1
752        && (normalized.ends_with('\\') || normalized.ends_with('/'))
753        && !normalized.ends_with(":\\")
754        && !normalized.ends_with(":/")
755        && normalized != "\\"
756        && normalized != "/"
757    {
758        normalized.pop();
759    }
760    normalized
761}
762
763pub fn normalize_project_root(path: &str) -> String {
764    normalize_absolute_path(path)
765}
766
767pub fn graph_match_key(path: &str) -> String {
768    let stripped =
769        crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
770    stripped.trim_start_matches('/').to_string()
771}
772
773pub fn graph_relative_key(path: &str, root: &str) -> String {
774    let root_norm = normalize_project_root(root);
775    let path_norm = normalize_absolute_path(path);
776    let root_path = Path::new(&root_norm);
777    let path_path = Path::new(&path_norm);
778
779    if let Ok(rel) = path_path.strip_prefix(root_path) {
780        let rel = rel.to_string_lossy().to_string();
781        return rel.trim_start_matches(['/', '\\']).to_string();
782    }
783
784    path.trim_start_matches(['/', '\\'])
785        .replace('/', std::path::MAIN_SEPARATOR_STR)
786}
787
788fn make_relative(path: &str, root: &str) -> String {
789    graph_relative_key(path, root)
790}
791
792fn is_indexable_ext(ext: &str) -> bool {
793    crate::core::language_capabilities::is_indexable_ext(ext)
794}
795
796#[cfg(test)]
797fn kotlin_package_name(content: &str) -> Option<String> {
798    content.lines().map(str::trim).find_map(|line| {
799        line.strip_prefix("package ")
800            .map(|rest| rest.trim().trim_end_matches(';').to_string())
801    })
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use tempfile::tempdir;
808
809    #[test]
810    fn test_short_hash_deterministic() {
811        let h1 = short_hash("/Users/test/project");
812        let h2 = short_hash("/Users/test/project");
813        assert_eq!(h1, h2);
814        assert_eq!(h1.len(), 8);
815    }
816
817    #[test]
818    fn test_make_relative() {
819        assert_eq!(
820            make_relative("/foo/bar/src/main.rs", "/foo/bar"),
821            graph_relative_key("/foo/bar/src/main.rs", "/foo/bar")
822        );
823        assert_eq!(
824            make_relative("src/main.rs", "/foo/bar"),
825            graph_relative_key("src/main.rs", "/foo/bar")
826        );
827        assert_eq!(
828            make_relative("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo"),
829            graph_relative_key("C:\\repo\\src\\main\\kotlin\\Example.kt", "C:\\repo")
830        );
831        assert_eq!(
832            make_relative("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo"),
833            graph_relative_key("//?/C:/repo/src/main/kotlin/Example.kt", "//?/C:/repo")
834        );
835    }
836
837    #[test]
838    fn test_normalize_project_root() {
839        assert_eq!(normalize_project_root("C:\\repo\\"), "C:\\repo");
840        assert_eq!(normalize_project_root("C:\\repo\\."), "C:\\repo");
841        assert_eq!(normalize_project_root("//?/C:/repo/"), "//?/C:/repo");
842    }
843
844    #[test]
845    fn test_graph_match_key_normalizes_windows_forms() {
846        assert_eq!(
847            graph_match_key(r"C:\repo\src\main.rs"),
848            "C:/repo/src/main.rs"
849        );
850        assert_eq!(
851            graph_match_key(r"\\?\C:\repo\src\main.rs"),
852            "C:/repo/src/main.rs"
853        );
854        assert_eq!(graph_match_key(r"\src\main.rs"), "src/main.rs");
855    }
856
857    #[test]
858    fn test_extract_summary() {
859        let content = "// comment\nuse std::io;\n\npub fn main() {\n    println!(\"hello\");\n}";
860        let summary = extract_summary(content);
861        assert_eq!(summary, "pub fn main() {");
862    }
863
864    #[test]
865    fn test_compute_hash_deterministic() {
866        let h1 = compute_hash("hello world");
867        let h2 = compute_hash("hello world");
868        assert_eq!(h1, h2);
869        assert_ne!(h1, compute_hash("hello world!"));
870    }
871
872    #[test]
873    fn test_project_index_new() {
874        let idx = ProjectIndex::new("/test");
875        assert_eq!(idx.version, INDEX_VERSION);
876        assert_eq!(idx.project_root, "/test");
877        assert!(idx.files.is_empty());
878    }
879
880    fn fe(path: &str, content: &str, language: &str) -> FileEntry {
881        FileEntry {
882            path: path.to_string(),
883            hash: compute_hash(content),
884            language: language.to_string(),
885            line_count: content.lines().count(),
886            token_count: crate::core::tokens::count_tokens(content),
887            exports: Vec::new(),
888            summary: extract_summary(content),
889        }
890    }
891
892    #[test]
893    fn test_index_looks_stale_when_any_file_missing() {
894        let td = tempdir().expect("tempdir");
895        let root = td.path();
896        std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
897
898        let root_s = normalize_project_root(&root.to_string_lossy());
899        let mut idx = ProjectIndex::new(&root_s);
900        idx.files
901            .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
902        idx.files.insert(
903            "missing.rs".to_string(),
904            fe("missing.rs", "pub fn m() {}\n", "rs"),
905        );
906
907        assert!(index_looks_stale(&idx, &root_s));
908    }
909
910    #[test]
911    fn test_index_looks_fresh_when_all_files_exist() {
912        let td = tempdir().expect("tempdir");
913        let root = td.path();
914        std::fs::write(root.join("a.rs"), "pub fn a() {}\n").expect("write a.rs");
915
916        let root_s = normalize_project_root(&root.to_string_lossy());
917        let mut idx = ProjectIndex::new(&root_s);
918        idx.files
919            .insert("a.rs".to_string(), fe("a.rs", "pub fn a() {}\n", "rs"));
920
921        assert!(!index_looks_stale(&idx, &root_s));
922    }
923
924    #[test]
925    fn test_reverse_deps() {
926        let mut idx = ProjectIndex::new("/test");
927        idx.edges.push(IndexEdge {
928            from: "a.rs".to_string(),
929            to: "b.rs".to_string(),
930            kind: "import".to_string(),
931        });
932        idx.edges.push(IndexEdge {
933            from: "c.rs".to_string(),
934            to: "b.rs".to_string(),
935            kind: "import".to_string(),
936        });
937
938        let deps = idx.get_reverse_deps("b.rs", 1);
939        assert_eq!(deps.len(), 2);
940        assert!(deps.contains(&"a.rs".to_string()));
941        assert!(deps.contains(&"c.rs".to_string()));
942    }
943
944    #[test]
945    fn test_find_symbol_range_kotlin_function() {
946        let content = r#"
947package com.example
948
949class UserService {
950    fun greet(name: String): String {
951        return "hi $name"
952    }
953}
954"#;
955        let sig = signatures::Signature {
956            kind: "method",
957            name: "greet".to_string(),
958            params: "name:String".to_string(),
959            return_type: "String".to_string(),
960            is_async: false,
961            is_exported: true,
962            indent: 2,
963            ..signatures::Signature::no_span()
964        };
965        let (start, end) = find_symbol_range(content, &sig);
966        assert_eq!(start, 5);
967        assert!(end >= start);
968    }
969
970    #[test]
971    fn test_signature_spans_override_fallback_range() {
972        let sig = signatures::Signature {
973            kind: "method",
974            name: "release".to_string(),
975            params: "id:String".to_string(),
976            return_type: "Boolean".to_string(),
977            is_async: true,
978            is_exported: true,
979            indent: 2,
980            start_line: Some(42),
981            end_line: Some(43),
982        };
983
984        let (start, end) = sig
985            .start_line
986            .zip(sig.end_line)
987            .unwrap_or_else(|| find_symbol_range("ignored", &sig));
988        assert_eq!((start, end), (42, 43));
989    }
990
991    #[test]
992    fn test_parse_stale_index_version() {
993        let json = format!(
994            r#"{{"version":{},"project_root":"/test","last_scan":"now","files":{{}},"edges":[],"symbols":{{}}}}"#,
995            INDEX_VERSION - 1
996        );
997        let parsed: ProjectIndex = serde_json::from_str(&json).unwrap();
998        assert_ne!(parsed.version, INDEX_VERSION);
999    }
1000
1001    #[test]
1002    fn test_kotlin_package_name() {
1003        let content = "package com.example.feature\n\nclass UserService";
1004        assert_eq!(
1005            kotlin_package_name(content).as_deref(),
1006            Some("com.example.feature")
1007        );
1008    }
1009
1010    #[test]
1011    fn safe_scan_root_rejects_fs_root() {
1012        assert!(!is_safe_scan_root("/"));
1013        assert!(!is_safe_scan_root("\\"));
1014        #[cfg(windows)]
1015        {
1016            assert!(!is_safe_scan_root("C:\\"));
1017            assert!(!is_safe_scan_root("D:\\"));
1018        }
1019    }
1020
1021    #[test]
1022    fn safe_scan_root_rejects_home() {
1023        if let Some(home) = dirs::home_dir() {
1024            let home_str = home.to_string_lossy().to_string();
1025            assert!(
1026                !is_safe_scan_root(&home_str),
1027                "home dir should be rejected: {home_str}"
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn safe_scan_root_accepts_project_dir() {
1034        let tmp = tempdir().unwrap();
1035        std::fs::write(
1036            tmp.path().join("Cargo.toml"),
1037            "[package]\nname = \"test\"\n",
1038        )
1039        .unwrap();
1040        let root = tmp.path().to_string_lossy().to_string();
1041        assert!(is_safe_scan_root(&root));
1042    }
1043
1044    #[test]
1045    fn safe_scan_root_rejects_broad_dir() {
1046        let tmp = tempdir().unwrap();
1047        for i in 0..55 {
1048            std::fs::create_dir(tmp.path().join(format!("dir{i}"))).unwrap();
1049        }
1050        let root = tmp.path().to_string_lossy().to_string();
1051        assert!(!is_safe_scan_root(&root));
1052    }
1053
1054    #[test]
1055    fn no_index_env_skips_scan() {
1056        let _env = crate::core::data_dir::test_env_lock();
1057        let tmp = tempdir().unwrap();
1058        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1059        std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
1060
1061        std::env::set_var("LEAN_CTX_NO_INDEX", "1");
1062        let idx = scan(&tmp.path().to_string_lossy());
1063        std::env::remove_var("LEAN_CTX_NO_INDEX");
1064        assert!(idx.files.is_empty(), "LEAN_CTX_NO_INDEX should skip scan");
1065    }
1066}