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