Skip to main content

codelens_engine/import_graph/
mod.rs

1pub(crate) mod parsers;
2mod resolvers;
3
4use crate::db::{IndexDb, index_db_path};
5use crate::project::{ProjectRoot, collect_files};
6use anyhow::{Result, bail};
7use serde::Serialize;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12
13// ── Re-exports ───────────────────────────────────────────────────────────────
14
15pub use parsers::extract_imports_for_file;
16pub use parsers::extract_imports_from_source;
17pub use resolvers::resolve_module_for_file;
18
19/// Use lang_registry as the single source of truth for supported extensions.
20pub fn is_import_supported(ext: &str) -> bool {
21    crate::lang_registry::supports_imports(ext)
22}
23
24// ── Types ────────────────────────────────────────────────────────────────────
25
26#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct BlastRadiusEntry {
28    pub file: String,
29    pub depth: usize,
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct ImporterEntry {
34    pub file: String,
35}
36
37#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
38pub struct ImportanceEntry {
39    pub file: String,
40    pub score: String,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
44pub struct DeadCodeEntry {
45    pub file: String,
46    pub symbol: Option<String>,
47    pub reason: String,
48}
49
50#[derive(Debug, Clone)]
51pub struct FileNode {
52    pub(crate) imports: HashSet<String>,
53    pub(crate) imported_by: HashSet<String>,
54}
55
56// ── GraphCache ───────────────────────────────────────────────────────────────
57
58pub struct GraphCache {
59    inner: Mutex<GraphCacheInner>,
60    /// Monotonically increasing counter -- bumped on every invalidation.
61    generation: AtomicU64,
62}
63
64struct GraphCacheInner {
65    graph: Option<Arc<HashMap<String, FileNode>>>,
66    /// PageRank scores computed against the same generation as `graph`.
67    /// Computed lazily on first `file_pagerank_scores` call after each
68    /// rebuild; reused across requests until the next invalidation.
69    pagerank: Option<Arc<HashMap<String, f64>>>,
70    /// Semantic coupling scores injected from the embedding layer.
71    /// Multiplied against PageRank scores in `file_pagerank_scores`.
72    semantic_scores: Option<Arc<HashMap<String, f64>>>,
73    /// Generation at which this cache entry was built.
74    built_generation: u64,
75}
76
77impl GraphCache {
78    /// Create a new cache.  The `_ttl_secs` parameter is kept for API
79    /// compatibility but no longer used -- invalidation is generation-based.
80    pub fn new(_ttl_secs: u64) -> Self {
81        Self {
82            inner: Mutex::new(GraphCacheInner {
83                graph: None,
84                pagerank: None,
85                semantic_scores: None,
86                built_generation: 0,
87            }),
88            generation: AtomicU64::new(1), // start at 1 so default 0 is always stale
89        }
90    }
91
92    pub fn get_or_build(&self, project: &ProjectRoot) -> Result<Arc<HashMap<String, FileNode>>> {
93        let current_gen = self.generation.load(Ordering::Acquire);
94        let mut inner = self
95            .inner
96            .lock()
97            .map_err(|_| anyhow::anyhow!("graph cache lock poisoned"))?;
98        if let Some(graph) = &inner.graph
99            && inner.built_generation == current_gen
100        {
101            return Ok(Arc::clone(graph));
102        }
103        let graph = Arc::new(build_graph(project)?);
104        inner.graph = Some(Arc::clone(&graph));
105        // Graph rebuild always invalidates the PageRank cache — the next
106        // call to `file_pagerank_scores` will recompute against the new
107        // graph. Without this drop, a stale PR map could survive a graph
108        // rebuild on the same generation tick and return scores keyed to
109        // a graph that no longer exists.
110        inner.pagerank = None;
111        inner.built_generation = current_gen;
112        Ok(graph)
113    }
114
115    /// Return per-file PageRank scores keyed to the current graph
116    /// generation. Computed on first call after each rebuild and reused
117    /// across requests until invalidation. The returned `Arc` lets
118    /// callers borrow the map without cloning the inner HashMap.
119    pub fn file_pagerank_scores(&self, project: &ProjectRoot) -> Arc<HashMap<String, f64>> {
120        // Fast path: lock once, check cache. Avoids running compute under
121        // the lock so concurrent searchers don't serialise on PageRank.
122        let current_gen = self.generation.load(Ordering::Acquire);
123        if let Ok(inner) = self.inner.lock()
124            && inner.built_generation == current_gen
125            && let Some(pr) = inner.pagerank.as_ref()
126        {
127            let scores = if let Some(semantic) = inner.semantic_scores.as_ref() {
128                let merged: HashMap<String, f64> = pr
129                    .iter()
130                    .map(|(file, pr_score)| {
131                        let sem_boost = semantic.get(file).copied().unwrap_or(0.0);
132                        // Multiply PageRank by (1 + semantic_boost), cap at 5.0x
133                        let boosted = pr_score * (1.0 + sem_boost).min(5.0);
134                        (file.clone(), boosted)
135                    })
136                    .collect();
137                Arc::new(merged)
138            } else {
139                Arc::clone(pr)
140            };
141            return scores;
142        }
143        // Slow path: rebuild graph if needed, then compute + memoize.
144        let graph = match self.get_or_build(project) {
145            Ok(g) => g,
146            Err(_) => return Arc::new(HashMap::new()),
147        };
148        let pr = Arc::new(compute_pagerank(&graph));
149        if let Ok(mut inner) = self.inner.lock() {
150            let sem = inner.semantic_scores.as_ref();
151            let result = if let Some(semantic) = sem {
152                let merged: HashMap<String, f64> = pr
153                    .iter()
154                    .map(|(file, pr_score)| {
155                        let sem_boost = semantic.get(file).copied().unwrap_or(0.0);
156                        let boosted = pr_score * (1.0 + sem_boost).min(5.0);
157                        (file.clone(), boosted)
158                    })
159                    .collect();
160                Arc::new(merged)
161            } else {
162                Arc::clone(&pr)
163            };
164            // Re-check generation under lock — another writer may have
165            // bumped it while we were computing. If so, leave their
166            // (newer) cache in place and return our computed result for
167            // this request without poisoning the cache.
168            let still_current = self.generation.load(Ordering::Acquire);
169            if inner.built_generation == still_current {
170                inner.pagerank = Some(pr);
171            }
172            result
173        } else {
174            Arc::clone(&pr)
175        }
176    }
177
178    /// Bump the generation counter, causing the next `get_or_build` to rebuild.
179    pub fn invalidate(&self) {
180        self.generation.fetch_add(1, Ordering::Release);
181    }
182
183    /// Current generation (for diagnostics / testing).
184    pub fn generation(&self) -> u64 {
185        self.generation.load(Ordering::Relaxed)
186    }
187
188    /// Inject semantic coupling scores from the embedding layer.
189    /// These scores are multiplied against PageRank scores (capped at 5.0x).
190    /// Call `invalidate()` after injection to force recompute on next query.
191    pub fn inject_semantic_scores(&self, scores: Arc<HashMap<String, f64>>) {
192        if let Ok(mut inner) = self.inner.lock() {
193            inner.semantic_scores = Some(scores);
194        }
195    }
196}
197
198// ── Public API functions ─────────────────────────────────────────────────────
199
200pub fn supports_import_graph(file_path: &str) -> bool {
201    crate::lang_registry::supports_imports_for_path(Path::new(file_path))
202}
203
204pub fn get_blast_radius(
205    project: &ProjectRoot,
206    file_path: &str,
207    max_depth: usize,
208    cache: &GraphCache,
209) -> Result<Vec<BlastRadiusEntry>> {
210    if !supports_import_graph(file_path) {
211        bail!("unsupported import-graph language for '{file_path}'");
212    }
213
214    let graph = cache.get_or_build(project)?;
215    let target = normalize_key(file_path);
216    let mut result = HashMap::new();
217    let mut queue = VecDeque::from([(target.clone(), 0usize)]);
218
219    while let Some((current, depth)) = queue.pop_front() {
220        if depth > max_depth || result.contains_key(&current) {
221            continue;
222        }
223        if current != target {
224            result.insert(current.clone(), depth);
225        }
226
227        let Some(node) = graph.get(&current) else {
228            continue;
229        };
230        for importer in &node.imported_by {
231            if !result.contains_key(importer) {
232                queue.push_back((importer.clone(), depth + 1));
233            }
234        }
235    }
236
237    let mut entries: Vec<_> = result
238        .into_iter()
239        .map(|(file, depth)| BlastRadiusEntry { file, depth })
240        .collect();
241    entries.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.file.cmp(&b.file)));
242    Ok(entries)
243}
244
245pub fn get_importers(
246    project: &ProjectRoot,
247    file_path: &str,
248    max_results: usize,
249    cache: &GraphCache,
250) -> Result<Vec<ImporterEntry>> {
251    if !supports_import_graph(file_path) {
252        bail!("unsupported import-graph language for '{file_path}'");
253    }
254
255    let graph = cache.get_or_build(project)?;
256    let target = normalize_key(file_path);
257    let importers = graph
258        .get(&target)
259        .map(|node| {
260            let mut entries = node
261                .imported_by
262                .iter()
263                .cloned()
264                .map(|file| ImporterEntry { file })
265                .collect::<Vec<_>>();
266            entries.sort_by(|a, b| a.file.cmp(&b.file));
267            if max_results > 0 && entries.len() > max_results {
268                entries.truncate(max_results);
269            }
270            entries
271        })
272        .unwrap_or_default();
273    Ok(importers)
274}
275
276/// PageRank over the import graph (damping=0.85, 20 iterations).
277fn compute_pagerank(graph: &HashMap<String, FileNode>) -> HashMap<String, f64> {
278    if graph.is_empty() {
279        return HashMap::new();
280    }
281    let damping = 0.85;
282    let n = graph.len() as f64;
283    let mut scores: HashMap<String, f64> = graph.keys().cloned().map(|k| (k, 1.0 / n)).collect();
284    let out_degree: HashMap<&str, usize> = graph
285        .iter()
286        .map(|(k, node)| (k.as_str(), node.imports.len()))
287        .collect();
288    for _ in 0..20 {
289        let mut next: HashMap<String, f64> = HashMap::new();
290        for (key, node) in graph.iter() {
291            let mut incoming = 0.0;
292            for importer in &node.imported_by {
293                let importer_score = scores.get(importer).copied().unwrap_or(0.0);
294                let degree = out_degree
295                    .get(importer.as_str())
296                    .copied()
297                    .unwrap_or(1)
298                    .max(1) as f64;
299                incoming += importer_score / degree;
300            }
301            next.insert(key.clone(), (1.0 - damping) / n + damping * incoming);
302        }
303        scores = next;
304    }
305    scores
306}
307
308pub fn get_importance(
309    project: &ProjectRoot,
310    top_n: usize,
311    cache: &GraphCache,
312) -> Result<Vec<ImportanceEntry>> {
313    let graph = cache.get_or_build(project)?;
314    let scores = compute_pagerank(&graph);
315
316    let mut ranked: Vec<_> = scores.into_iter().collect();
317    ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
318    let mut entries: Vec<_> = ranked
319        .into_iter()
320        .map(|(file, score)| ImportanceEntry {
321            file,
322            score: format!("{score:.4}"),
323        })
324        .collect();
325    if top_n > 0 && entries.len() > top_n {
326        entries.truncate(top_n);
327    }
328    Ok(entries)
329}
330
331/// Public accessor for the import graph, used by sibling modules (e.g. circular).
332pub(crate) fn build_graph_pub(
333    project: &ProjectRoot,
334    cache: &GraphCache,
335) -> Result<Arc<HashMap<String, FileNode>>> {
336    cache.get_or_build(project)
337}
338
339// ── Graph building (internal) ────────────────────────────────────────────────
340
341fn build_graph(project: &ProjectRoot) -> Result<HashMap<String, FileNode>> {
342    // Try to load from SQLite first
343    let db_path = index_db_path(project.as_path());
344    if db_path.is_file()
345        && let Ok(db) = IndexDb::open(&db_path)
346        && db.file_count()? > 0
347    {
348        return build_graph_from_db(&db);
349    }
350
351    // Fallback: scan files directly
352    build_graph_from_files(project)
353}
354
355fn build_graph_from_db(db: &IndexDb) -> Result<HashMap<String, FileNode>> {
356    let db_graph = db.build_import_graph()?;
357    let mut graph = HashMap::new();
358    for (path, (imports, imported_by)) in db_graph {
359        graph.insert(
360            path,
361            FileNode {
362                imports: imports.into_iter().collect(),
363                imported_by: imported_by.into_iter().collect(),
364            },
365        );
366    }
367    Ok(graph)
368}
369
370fn build_graph_from_files(project: &ProjectRoot) -> Result<HashMap<String, FileNode>> {
371    let files = collect_candidate_files(project.as_path())?;
372    let mut graph = HashMap::new();
373
374    for file in &files {
375        let rel = project.to_relative(file);
376        let imports = parsers::extract_imports(file)
377            .into_iter()
378            .filter_map(|module| resolvers::resolve_module(project, file, &module))
379            .collect::<HashSet<_>>();
380        graph.insert(
381            rel.clone(),
382            FileNode {
383                imports,
384                imported_by: HashSet::new(),
385            },
386        );
387    }
388
389    let edges: Vec<(String, String)> = graph
390        .iter()
391        .flat_map(|(from_file, node)| {
392            node.imports
393                .iter()
394                .cloned()
395                .map(|to_file| (from_file.clone(), to_file))
396                .collect::<Vec<_>>()
397        })
398        .collect();
399
400    for (from_file, to_file) in edges {
401        if let Some(node) = graph.get_mut(&to_file) {
402            node.imported_by.insert(from_file);
403        }
404    }
405
406    Ok(graph)
407}
408
409pub(crate) fn collect_candidate_files(root: &Path) -> Result<Vec<PathBuf>> {
410    collect_files(root, |path| {
411        crate::lang_registry::supports_imports_for_path(path)
412    })
413}
414
415fn normalize_key(file_path: &str) -> String {
416    file_path.replace('\\', "/")
417}
418
419// ── Tests ────────────────────────────────────────────────────────────────────
420
421#[cfg(test)]
422mod tests {
423    use super::{
424        GraphCache, get_blast_radius, get_importance, get_importers, supports_import_graph,
425    };
426    use crate::ProjectRoot;
427    use crate::dead_code::find_dead_code;
428    use std::fs;
429
430    #[test]
431    fn calculates_python_blast_radius() {
432        let dir = temp_project_dir("python");
433        fs::write(
434            dir.join("main.py"),
435            "from utils import greet\n\ndef main():\n    return greet()\n",
436        )
437        .expect("write main");
438        fs::write(
439            dir.join("utils.py"),
440            "from models import User\n\ndef greet():\n    return User()\n",
441        )
442        .expect("write utils");
443        fs::write(dir.join("models.py"), "class User:\n    pass\n").expect("write models");
444
445        let project = ProjectRoot::new(&dir).expect("project");
446        let cache = GraphCache::new(0);
447        let radius = get_blast_radius(&project, "models.py", 3, &cache).expect("blast radius");
448        assert_eq!(
449            radius,
450            vec![
451                super::BlastRadiusEntry {
452                    file: "utils.py".to_owned(),
453                    depth: 1,
454                },
455                super::BlastRadiusEntry {
456                    file: "main.py".to_owned(),
457                    depth: 2,
458                },
459            ]
460        );
461    }
462
463    #[test]
464    fn calculates_typescript_blast_radius() {
465        let dir = temp_project_dir("typescript");
466        fs::create_dir_all(dir.join("lib")).expect("mkdir");
467        fs::write(
468            dir.join("app.ts"),
469            "import { greet } from './lib/greet'\nconsole.log(greet())\n",
470        )
471        .expect("write app");
472        fs::write(
473            dir.join("lib/greet.ts"),
474            "import { User } from './user'\nexport const greet = () => new User()\n",
475        )
476        .expect("write greet");
477        fs::write(dir.join("lib/user.ts"), "export class User {}\n").expect("write user");
478
479        let project = ProjectRoot::new(&dir).expect("project");
480        let cache = GraphCache::new(0);
481        let radius = get_blast_radius(&project, "lib/user.ts", 3, &cache).expect("blast radius");
482        assert_eq!(
483            radius,
484            vec![
485                super::BlastRadiusEntry {
486                    file: "lib/greet.ts".to_owned(),
487                    depth: 1,
488                },
489                super::BlastRadiusEntry {
490                    file: "app.ts".to_owned(),
491                    depth: 2,
492                },
493            ]
494        );
495    }
496
497    #[test]
498    fn reports_supported_extensions() {
499        assert!(supports_import_graph("main.py"));
500        assert!(supports_import_graph("main.ts"));
501        assert!(supports_import_graph("Main.java"));
502        assert!(supports_import_graph("main.go"));
503        assert!(supports_import_graph("main.kt"));
504        assert!(supports_import_graph("main.rs"));
505        assert!(supports_import_graph("main.rb"));
506        assert!(supports_import_graph("main.c"));
507        assert!(supports_import_graph("main.cpp"));
508        assert!(supports_import_graph("main.h"));
509        assert!(supports_import_graph("main.php"));
510        assert!(supports_import_graph("main.swift"));
511        assert!(supports_import_graph("main.scala"));
512        assert!(supports_import_graph("main.css"));
513    }
514
515    #[test]
516    fn extracts_go_imports() {
517        let content = r#"
518package main
519
520import "fmt"
521import (
522    "os"
523    "path/filepath"
524)
525"#;
526        let imports = super::parsers::extract_go_imports(content);
527        assert!(imports.contains(&"fmt".to_owned()), "single import");
528        assert!(imports.contains(&"os".to_owned()), "block import os");
529        assert!(
530            imports.contains(&"path/filepath".to_owned()),
531            "block import path"
532        );
533    }
534
535    #[test]
536    fn extracts_java_imports() {
537        let content = "import com.example.Foo;\nimport static com.example.Utils.helper;\n";
538        let imports = super::parsers::extract_java_imports(content);
539        assert!(imports.contains(&"com.example.Foo".to_owned()));
540        assert!(imports.contains(&"com.example.Utils.helper".to_owned()));
541    }
542
543    #[test]
544    fn extracts_kotlin_imports() {
545        let content = "import com.example.Foo\nimport com.example.Bar as B\n";
546        let imports = super::parsers::extract_kotlin_imports(content);
547        assert!(imports.contains(&"com.example.Foo".to_owned()));
548        assert!(imports.contains(&"com.example.Bar".to_owned()));
549    }
550
551    #[test]
552    fn extracts_rust_imports() {
553        let content = "use crate::utils;\nuse super::models;\nmod config;\n";
554        let imports = super::parsers::extract_rust_imports(content);
555        assert!(imports.contains(&"crate::utils".to_owned()));
556        assert!(imports.contains(&"super::models".to_owned()));
557        assert!(imports.contains(&"config".to_owned()));
558    }
559
560    #[test]
561    fn extracts_rust_pub_mod_and_pub_use() {
562        let content =
563            "pub mod symbols;\npub(crate) mod db;\npub use crate::project::ProjectRoot;\n";
564        let imports = super::parsers::extract_rust_imports(content);
565        assert!(
566            imports.contains(&"symbols".to_owned()),
567            "pub mod should be captured"
568        );
569        assert!(
570            imports.contains(&"db".to_owned()),
571            "pub(crate) mod should be captured"
572        );
573        assert!(
574            imports.contains(&"crate::project::ProjectRoot".to_owned()),
575            "pub use should be captured"
576        );
577    }
578
579    #[test]
580    fn extracts_rust_brace_group_imports() {
581        let content = "use crate::{symbols, db};\nuse crate::foo::{Bar, Baz};\n";
582        let imports = super::parsers::extract_rust_imports(content);
583        assert!(
584            imports.contains(&"crate::symbols".to_owned()),
585            "brace group item 1"
586        );
587        assert!(
588            imports.contains(&"crate::db".to_owned()),
589            "brace group item 2"
590        );
591        assert!(
592            imports.contains(&"crate::foo::Bar".to_owned()),
593            "nested brace 1"
594        );
595        assert!(
596            imports.contains(&"crate::foo::Baz".to_owned()),
597            "nested brace 2"
598        );
599    }
600
601    #[test]
602    fn extracts_ruby_imports() {
603        let content = "require \"json\"\nrequire_relative \"../lib/helper\"\nload \"tasks.rb\"\n";
604        let imports = super::parsers::extract_ruby_imports(content);
605        assert!(imports.contains(&"json".to_owned()));
606        assert!(imports.contains(&"../lib/helper".to_owned()));
607        assert!(imports.contains(&"tasks.rb".to_owned()));
608    }
609
610    #[test]
611    fn extracts_c_imports() {
612        let content = "#include \"mylib.h\"\n#include <stdio.h>\n";
613        let imports = super::parsers::extract_c_imports(content);
614        assert!(imports.contains(&"mylib.h".to_owned()));
615        assert!(imports.contains(&"stdio.h".to_owned()));
616    }
617
618    #[test]
619    fn extracts_php_imports() {
620        let content =
621            "use App\\Http\\Controllers\\HomeController;\nrequire \"vendor/autoload.php\";\n";
622        let imports = super::parsers::extract_php_imports(content);
623        assert!(imports.contains(&"App\\Http\\Controllers\\HomeController".to_owned()));
624        assert!(imports.contains(&"vendor/autoload.php".to_owned()));
625    }
626
627    #[test]
628    fn returns_importers() {
629        let dir = temp_project_dir("importers");
630        fs::write(
631            dir.join("main.py"),
632            "from utils import greet\n\ndef main():\n    return greet()\n",
633        )
634        .expect("write main");
635        fs::write(
636            dir.join("worker.py"),
637            "from utils import greet\n\ndef run():\n    return greet()\n",
638        )
639        .expect("write worker");
640        fs::write(dir.join("utils.py"), "def greet():\n    return 1\n").expect("write utils");
641
642        let project = ProjectRoot::new(&dir).expect("project");
643        let cache = GraphCache::new(0);
644        let importers = get_importers(&project, "utils.py", 10, &cache).expect("importers");
645        assert_eq!(
646            importers,
647            vec![
648                super::ImporterEntry {
649                    file: "main.py".to_owned(),
650                },
651                super::ImporterEntry {
652                    file: "worker.py".to_owned(),
653                },
654            ]
655        );
656    }
657
658    #[test]
659    fn returns_importance_ranking() {
660        let dir = temp_project_dir("importance");
661        fs::write(
662            dir.join("main.py"),
663            "from utils import greet\n\ndef main():\n    return greet()\n",
664        )
665        .expect("write main");
666        fs::write(
667            dir.join("worker.py"),
668            "from utils import greet\n\ndef run():\n    return greet()\n",
669        )
670        .expect("write worker");
671        fs::write(
672            dir.join("utils.py"),
673            "from models import User\n\ndef greet():\n    return User()\n",
674        )
675        .expect("write utils");
676        fs::write(dir.join("models.py"), "class User:\n    pass\n").expect("write models");
677
678        let project = ProjectRoot::new(&dir).expect("project");
679        let cache = GraphCache::new(0);
680        let ranking = get_importance(&project, 10, &cache).expect("importance");
681        assert!(!ranking.is_empty());
682        assert_eq!(
683            ranking.first().map(|it| it.file.as_str()),
684            Some("models.py")
685        );
686        assert!(ranking.iter().all(|it| !it.score.is_empty()));
687    }
688
689    #[test]
690    fn returns_dead_code_candidates() {
691        let dir = temp_project_dir("dead-code");
692        fs::write(
693            dir.join("main.py"),
694            "from utils import greet\n\ndef main():\n    return greet()\n",
695        )
696        .expect("write main");
697        fs::write(dir.join("utils.py"), "def greet():\n    return 1\n").expect("write utils");
698        fs::write(dir.join("unused.py"), "def helper():\n    return 2\n").expect("write unused");
699
700        let project = ProjectRoot::new(&dir).expect("project");
701        let cache = GraphCache::new(0);
702        let dead = find_dead_code(&project, 10, &cache).expect("dead code");
703        assert_eq!(
704            dead,
705            vec![
706                super::DeadCodeEntry {
707                    file: "main.py".to_owned(),
708                    symbol: None,
709                    reason: "no importers".to_owned(),
710                },
711                super::DeadCodeEntry {
712                    file: "unused.py".to_owned(),
713                    symbol: None,
714                    reason: "no importers".to_owned(),
715                },
716            ]
717        );
718    }
719
720    #[test]
721    fn resolves_cross_crate_workspace_imports() {
722        let dir = temp_project_dir("cross-crate");
723        let core_src = dir.join("crates").join("codelens-core").join("src");
724        let mcp_src = dir.join("crates").join("codelens-mcp").join("src");
725        fs::create_dir_all(&core_src).expect("mkdir core/src");
726        fs::create_dir_all(&mcp_src).expect("mkdir mcp/src");
727
728        fs::write(
729            dir.join("crates").join("codelens-core").join("Cargo.toml"),
730            "[package]\nname = \"codelens-core\"\n",
731        )
732        .expect("write core Cargo.toml");
733        fs::write(
734            dir.join("crates").join("codelens-mcp").join("Cargo.toml"),
735            "[package]\nname = \"codelens-mcp\"\n",
736        )
737        .expect("write mcp Cargo.toml");
738
739        fs::write(core_src.join("project.rs"), "pub struct ProjectRoot;\n")
740            .expect("write project.rs");
741
742        let main_rs = mcp_src.join("main.rs");
743        fs::write(
744            &main_rs,
745            "use codelens_core::project::ProjectRoot;\nfn main() {}\n",
746        )
747        .expect("write main.rs");
748
749        let project = ProjectRoot::new(&dir).expect("project");
750
751        let resolved = super::resolvers::resolve_module_for_file(
752            &project,
753            &main_rs,
754            "codelens_core::project::ProjectRoot",
755        );
756        assert_eq!(
757            resolved,
758            Some("crates/codelens-core/src/project.rs".to_owned()),
759            "cross-crate import should resolve to crates/codelens-core/src/project.rs"
760        );
761    }
762
763    fn temp_project_dir(name: &str) -> std::path::PathBuf {
764        let dir = std::env::temp_dir().join(format!(
765            "codelens-core-import-graph-{name}-{}",
766            std::time::SystemTime::now()
767                .duration_since(std::time::UNIX_EPOCH)
768                .expect("time")
769                .as_nanos()
770        ));
771        fs::create_dir_all(&dir).expect("create tempdir");
772        dir
773    }
774}