Skip to main content

the_code_graph_parser/resolver/
rust_lang.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use domain::model::{Edge, EdgeKind, Language};
5
6use super::{ImportResolver, ResolveContext};
7use crate::ParseResult;
8
9/// Configuration for the Rust resolver, loaded from Cargo.toml.
10pub struct RustConfig {
11    pub workspace_members: Vec<String>,
12    pub edition: Option<String>,
13}
14
15impl RustConfig {
16    pub fn load(project_root: &Path) -> Self {
17        let cargo_path = project_root.join("Cargo.toml");
18        let contents = match std::fs::read_to_string(&cargo_path) {
19            Ok(c) => c,
20            Err(_) => {
21                return Self {
22                    workspace_members: vec![],
23                    edition: None,
24                }
25            }
26        };
27        let table: toml::Table = match contents.parse() {
28            Ok(t) => t,
29            Err(_) => {
30                return Self {
31                    workspace_members: vec![],
32                    edition: None,
33                }
34            }
35        };
36        let workspace_members = table
37            .get("workspace")
38            .and_then(|w| w.get("members"))
39            .and_then(|m| m.as_array())
40            .map(|arr| {
41                arr.iter()
42                    .filter_map(|v| v.as_str().map(String::from))
43                    .collect()
44            })
45            .unwrap_or_default();
46        let edition = table
47            .get("package")
48            .and_then(|p| p.get("edition"))
49            .and_then(|e| e.as_str())
50            .map(String::from);
51        Self {
52            workspace_members,
53            edition,
54        }
55    }
56}
57
58/// Rust import resolver — module tree + use path resolution.
59pub struct RustResolver {
60    _config: RustConfig,
61}
62
63impl RustResolver {
64    pub fn new(config: RustConfig) -> Self {
65        Self { _config: config }
66    }
67}
68
69// ---------------------------------------------------------------------------
70// Module tree helpers
71// ---------------------------------------------------------------------------
72
73/// A mapping from crate-path prefix (e.g. "crate::auth") to the file that
74/// declares that module.
75type ModuleTree = HashMap<String, PathBuf>;
76
77/// Find the crate root file (`src/lib.rs` or `src/main.rs`) relative to project_root
78/// from the provided file_tree.
79fn find_crate_root(project_root: &Path, file_tree: &[PathBuf]) -> Option<PathBuf> {
80    let lib_rs = project_root.join("src").join("lib.rs");
81    let main_rs = project_root.join("src").join("main.rs");
82    for path in file_tree {
83        if path == &lib_rs || path == &main_rs {
84            return Some(path.clone());
85        }
86    }
87    None
88}
89
90/// Recursively build the module tree starting from `current_file`.
91/// `current_module_path` is the crate path to the current file (e.g. "crate" for the root).
92fn build_module_tree_recursive(
93    current_file: &Path,
94    current_module_path: &str,
95    parsed_files: &HashMap<PathBuf, ParseResult>,
96    file_tree: &[PathBuf],
97    tree: &mut ModuleTree,
98    visited: &mut Vec<PathBuf>,
99) {
100    // Cycle guard
101    if visited.contains(&current_file.to_path_buf()) {
102        return;
103    }
104    visited.push(current_file.to_path_buf());
105
106    let parse_result = match parsed_files.get(current_file) {
107        Some(pr) => pr,
108        None => return,
109    };
110
111    // Determine the directory in which submodule files live.
112    //
113    // Rust module-file placement rules:
114    //  - lib.rs / main.rs           → submodules in <parent>/
115    //  - foo/mod.rs                 → submodules in <parent>/ (== foo/)
116    //  - foo.rs                     → submodules in <parent>/foo/
117    //
118    // In all cases "parent" is the directory containing the current file.
119    let parent_dir = match current_file.parent() {
120        Some(d) => d.to_path_buf(),
121        None => return,
122    };
123
124    let file_name = current_file
125        .file_name()
126        .and_then(|n| n.to_str())
127        .unwrap_or("");
128    let is_root_file = matches!(file_name, "lib.rs" | "main.rs" | "mod.rs");
129
130    // For a flat file (e.g. auth.rs), submodules live in a same-named directory.
131    let submodule_dir = if is_root_file {
132        parent_dir.clone()
133    } else {
134        // Strip .rs extension to get directory name
135        let stem = current_file
136            .file_stem()
137            .and_then(|s| s.to_str())
138            .unwrap_or("");
139        parent_dir.join(stem)
140    };
141
142    for import in &parse_result.imports {
143        if !import.specifier.starts_with("mod::") {
144            continue;
145        }
146        let mod_name = &import.specifier["mod::".len()..];
147
148        // Resolve to file path: flat style (foo.rs) or legacy style (foo/mod.rs)
149        let flat = submodule_dir.join(format!("{mod_name}.rs"));
150        let legacy = submodule_dir.join(mod_name).join("mod.rs");
151
152        let mod_file = if file_tree.contains(&flat) {
153            flat
154        } else if file_tree.contains(&legacy) {
155            legacy
156        } else {
157            continue;
158        };
159
160        let child_module_path = format!("{current_module_path}::{mod_name}");
161        tree.insert(child_module_path.clone(), mod_file.clone());
162
163        build_module_tree_recursive(
164            &mod_file,
165            &child_module_path,
166            parsed_files,
167            file_tree,
168            tree,
169            visited,
170        );
171    }
172}
173
174/// Build the complete module tree for a crate, starting from the crate root.
175/// Returns a map of `"crate::foo::bar"` → resolved file path.
176fn build_module_tree(context: &ResolveContext) -> ModuleTree {
177    let mut tree = ModuleTree::new();
178
179    let crate_root = match find_crate_root(&context.project_root, &context.file_tree) {
180        Some(r) => r,
181        None => return tree,
182    };
183
184    let mut visited = Vec::new();
185    build_module_tree_recursive(
186        &crate_root,
187        "crate",
188        &context.parsed_files,
189        &context.file_tree,
190        &mut tree,
191        &mut visited,
192    );
193
194    tree
195}
196
197/// Given a file path, derive its crate module path (e.g. `src/auth/validate.rs` → `crate::auth::validate`).
198/// This is the inverse lookup: file → module path.
199fn file_to_module_path(
200    project_root: &Path,
201    file_path: &Path,
202    module_tree: &ModuleTree,
203) -> Option<String> {
204    for (module_path, mapped_file) in module_tree {
205        if mapped_file == file_path {
206            return Some(module_path.clone());
207        }
208    }
209
210    // Check if this is the crate root itself
211    let lib_rs = project_root.join("src").join("lib.rs");
212    let main_rs = project_root.join("src").join("main.rs");
213    if file_path == lib_rs || file_path == main_rs {
214        return Some("crate".to_string());
215    }
216
217    None
218}
219
220/// Resolve a `use` specifier to a target file path by walking the module tree.
221///
222/// - `crate::foo::bar` → look up `"crate::foo"` then `"crate::foo::bar"` in the tree
223/// - `self::sub` → resolve `self` to the current module's path, then recurse
224/// - `super::foo` → resolve `super` to the parent module's path, then recurse
225fn resolve_specifier_to_file(
226    specifier: &str,
227    file_path: &Path,
228    project_root: &Path,
229    module_tree: &ModuleTree,
230) -> Option<PathBuf> {
231    let segments: Vec<&str> = specifier.split("::").collect();
232    if segments.is_empty() {
233        return None;
234    }
235
236    // Determine the base module path for self/super resolution
237    let base_module_path = match segments[0] {
238        "self" => {
239            // "self" refers to the current module
240            file_to_module_path(project_root, file_path, module_tree)?
241        }
242        "super" => {
243            // "super" refers to the parent module
244            let current = file_to_module_path(project_root, file_path, module_tree)?;
245            // Strip one segment from the end
246            current.rsplit_once("::").map(|(p, _)| p.to_string())?
247        }
248        "crate" => "crate".to_string(),
249        _ => return None,
250    };
251
252    // Remaining segments after the keyword
253    let rest_segments = &segments[1..];
254
255    // For self:: and super:: paths, the base module file is the fallback when
256    // the remaining path refers to an item (function, struct, etc.) rather than
257    // a submodule. For crate:: paths we start with no fallback — if no segment
258    // matches a module in the tree, the path is unresolvable (external crate, std).
259    let is_relative = matches!(segments[0], "self" | "super");
260    let initial_resolved: Option<PathBuf> = if is_relative && base_module_path != "crate" {
261        module_tree.get(&base_module_path).cloned()
262    } else {
263        None
264    };
265
266    // Try progressively longer prefixes and return the deepest match.
267    // e.g. for crate::auth::validate::something:
268    //   try crate::auth → auth.rs           (resolved updated)
269    //   try crate::auth::validate → validate.rs (resolved updated)
270    //   try crate::auth::validate::something → not in tree, skip
271    let mut resolved = initial_resolved;
272    let mut candidate_path = base_module_path.clone();
273
274    for seg in rest_segments {
275        candidate_path = format!("{candidate_path}::{seg}");
276        if let Some(file) = module_tree.get(&candidate_path) {
277            resolved = Some(file.clone());
278        }
279    }
280
281    resolved
282}
283
284// ---------------------------------------------------------------------------
285// ImportResolver implementation
286// ---------------------------------------------------------------------------
287
288impl ImportResolver for RustResolver {
289    fn languages(&self) -> &[Language] {
290        &[Language::Rust]
291    }
292
293    fn resolve(
294        &self,
295        file_path: &Path,
296        parse_result: &ParseResult,
297        context: &ResolveContext,
298    ) -> domain::error::Result<Vec<Edge>> {
299        let module_tree = build_module_tree(context);
300        let mut edges = Vec::new();
301
302        let source_str = file_path.to_string_lossy().into_owned();
303
304        for import in &parse_result.imports {
305            // Skip mod declarations — they're not import edges
306            if import.specifier.starts_with("mod::") {
307                continue;
308            }
309
310            let Some(target_file) = resolve_specifier_to_file(
311                &import.specifier,
312                file_path,
313                &context.project_root,
314                &module_tree,
315            ) else {
316                continue;
317            };
318
319            let target_str = target_file.to_string_lossy().into_owned();
320
321            // Determine edge kind: ReExport for pub use, ImportsFrom otherwise
322            let is_reexport = parse_result
323                .exports
324                .iter()
325                .any(|e| e.is_reexport && e.source_specifier.as_deref() == Some(&import.specifier));
326
327            let edge_kind = if is_reexport {
328                EdgeKind::ReExport
329            } else {
330                EdgeKind::ImportsFrom
331            };
332
333            edges.push(Edge {
334                kind: edge_kind,
335                source: source_str.clone(),
336                target: target_str,
337                metadata: None,
338            });
339        }
340
341        Ok(edges)
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::collections::HashMap;
353
354    /// Helper: build a ResolveContext from in-memory data.
355    fn make_context(
356        project_root: PathBuf,
357        file_tree: Vec<PathBuf>,
358        parsed_files: HashMap<PathBuf, ParseResult>,
359    ) -> ResolveContext {
360        ResolveContext {
361            project_root,
362            parsed_files,
363            file_tree,
364        }
365    }
366
367    /// Helper: build a RawImport for a `mod::` declaration.
368    fn mod_import(name: &str) -> crate::RawImport {
369        crate::RawImport {
370            specifier: format!("mod::{name}"),
371            ..Default::default()
372        }
373    }
374
375    /// Helper: build a RawImport for a `use` path.
376    fn use_import(specifier: &str) -> crate::RawImport {
377        crate::RawImport {
378            specifier: specifier.to_string(),
379            ..Default::default()
380        }
381    }
382
383    /// Helper: build a pub-use Export entry (is_reexport = true).
384    fn pub_use_export(specifier: &str) -> crate::Export {
385        crate::Export {
386            name: specifier.rsplit("::").next().unwrap_or("").to_string(),
387            is_reexport: true,
388            source_specifier: Some(specifier.to_string()),
389            ..Default::default()
390        }
391    }
392
393    // -----------------------------------------------------------------------
394    // AC35: builds_module_tree_from_mod_declarations
395    // -----------------------------------------------------------------------
396    #[test]
397    fn builds_module_tree_from_mod_declarations() {
398        let root = PathBuf::from("/project");
399        let lib_rs = root.join("src/lib.rs");
400        let auth_rs = root.join("src/auth.rs");
401        let db_rs = root.join("src/db.rs");
402
403        let mut parsed_files = HashMap::new();
404        parsed_files.insert(
405            lib_rs.clone(),
406            ParseResult {
407                imports: vec![mod_import("auth"), mod_import("db")],
408                ..Default::default()
409            },
410        );
411        parsed_files.insert(auth_rs.clone(), ParseResult::default());
412        parsed_files.insert(db_rs.clone(), ParseResult::default());
413
414        let context = make_context(
415            root.clone(),
416            vec![lib_rs.clone(), auth_rs.clone(), db_rs.clone()],
417            parsed_files,
418        );
419
420        let tree = build_module_tree(&context);
421
422        assert!(
423            tree.contains_key("crate::auth"),
424            "module tree should contain crate::auth, got: {tree:?}"
425        );
426        assert_eq!(tree["crate::auth"], auth_rs);
427        assert!(
428            tree.contains_key("crate::db"),
429            "module tree should contain crate::db, got: {tree:?}"
430        );
431        assert_eq!(tree["crate::db"], db_rs);
432    }
433
434    // -----------------------------------------------------------------------
435    // AC36: resolves use crate::auth::validate to src/auth.rs
436    // -----------------------------------------------------------------------
437    #[test]
438    fn resolves_use_crate_path() {
439        let root = PathBuf::from("/project");
440        let lib_rs = root.join("src/lib.rs");
441        let auth_rs = root.join("src/auth.rs");
442        let main_rs = root.join("src/main.rs");
443
444        let mut parsed_files = HashMap::new();
445        parsed_files.insert(
446            lib_rs.clone(),
447            ParseResult {
448                imports: vec![mod_import("auth")],
449                ..Default::default()
450            },
451        );
452        parsed_files.insert(auth_rs.clone(), ParseResult::default());
453
454        let context = make_context(
455            root.clone(),
456            vec![lib_rs.clone(), auth_rs.clone()],
457            parsed_files,
458        );
459
460        let resolver = RustResolver::new(RustConfig {
461            workspace_members: vec![],
462            edition: None,
463        });
464        let importer = ParseResult {
465            imports: vec![use_import("crate::auth::validate")],
466            ..Default::default()
467        };
468
469        let edges = resolver.resolve(&main_rs, &importer, &context).unwrap();
470
471        assert_eq!(
472            edges.len(),
473            1,
474            "Expected one ImportsFrom edge, got: {edges:?}"
475        );
476        assert_eq!(edges[0].kind, EdgeKind::ImportsFrom);
477        assert_eq!(edges[0].source, main_rs.to_string_lossy());
478        assert_eq!(edges[0].target, auth_rs.to_string_lossy());
479    }
480
481    // -----------------------------------------------------------------------
482    // AC37: resolves use self::sub relative to current module
483    // -----------------------------------------------------------------------
484    #[test]
485    fn resolves_use_self_path() {
486        let root = PathBuf::from("/project");
487        let lib_rs = root.join("src/lib.rs");
488        let auth_rs = root.join("src/auth.rs");
489        let sub_rs = root.join("src/auth/sub.rs");
490
491        let mut parsed_files = HashMap::new();
492        parsed_files.insert(
493            lib_rs.clone(),
494            ParseResult {
495                imports: vec![mod_import("auth")],
496                ..Default::default()
497            },
498        );
499        // auth.rs declares a submodule `sub`
500        parsed_files.insert(
501            auth_rs.clone(),
502            ParseResult {
503                imports: vec![mod_import("sub")],
504                ..Default::default()
505            },
506        );
507        parsed_files.insert(sub_rs.clone(), ParseResult::default());
508
509        let context = make_context(
510            root.clone(),
511            vec![lib_rs.clone(), auth_rs.clone(), sub_rs.clone()],
512            parsed_files,
513        );
514
515        // auth.rs uses `use self::sub::something`
516        let resolver = RustResolver::new(RustConfig {
517            workspace_members: vec![],
518            edition: None,
519        });
520        let parse_result = ParseResult {
521            imports: vec![use_import("self::sub::something")],
522            ..Default::default()
523        };
524
525        let edges = resolver.resolve(&auth_rs, &parse_result, &context).unwrap();
526
527        assert_eq!(
528            edges.len(),
529            1,
530            "Expected one ImportsFrom edge, got: {edges:?}"
531        );
532        assert_eq!(edges[0].kind, EdgeKind::ImportsFrom);
533        assert_eq!(edges[0].source, auth_rs.to_string_lossy());
534        assert_eq!(edges[0].target, sub_rs.to_string_lossy());
535    }
536
537    // -----------------------------------------------------------------------
538    // AC38: creates ReExport edge for pub use statements
539    // -----------------------------------------------------------------------
540    #[test]
541    fn creates_reexport_edge_for_pub_use() {
542        let root = PathBuf::from("/project");
543        let lib_rs = root.join("src/lib.rs");
544        let auth_rs = root.join("src/auth.rs");
545
546        let mut parsed_files = HashMap::new();
547        parsed_files.insert(
548            lib_rs.clone(),
549            ParseResult {
550                imports: vec![mod_import("auth")],
551                ..Default::default()
552            },
553        );
554        parsed_files.insert(auth_rs.clone(), ParseResult::default());
555
556        let context = make_context(
557            root.clone(),
558            vec![lib_rs.clone(), auth_rs.clone()],
559            parsed_files,
560        );
561
562        // lib.rs does `pub use crate::auth::validate;`
563        let resolver = RustResolver::new(RustConfig {
564            workspace_members: vec![],
565            edition: None,
566        });
567        let parse_result = ParseResult {
568            imports: vec![use_import("crate::auth::validate")],
569            exports: vec![pub_use_export("crate::auth::validate")],
570            ..Default::default()
571        };
572
573        let edges = resolver.resolve(&lib_rs, &parse_result, &context).unwrap();
574
575        assert_eq!(edges.len(), 1, "Expected one ReExport edge, got: {edges:?}");
576        assert_eq!(edges[0].kind, EdgeKind::ReExport);
577        assert_eq!(edges[0].source, lib_rs.to_string_lossy());
578        assert_eq!(edges[0].target, auth_rs.to_string_lossy());
579    }
580
581    // -----------------------------------------------------------------------
582    // AC39: handles both foo.rs and foo/mod.rs naming conventions
583    // -----------------------------------------------------------------------
584    #[test]
585    fn handles_both_foo_rs_and_foo_mod_rs() {
586        let root = PathBuf::from("/project");
587        let lib_rs = root.join("src/lib.rs");
588        // flat style: auth.rs
589        let auth_rs = root.join("src/auth.rs");
590        // legacy style: db/mod.rs
591        let db_mod_rs = root.join("src/db/mod.rs");
592
593        let mut parsed_files = HashMap::new();
594        parsed_files.insert(
595            lib_rs.clone(),
596            ParseResult {
597                imports: vec![mod_import("auth"), mod_import("db")],
598                ..Default::default()
599            },
600        );
601        parsed_files.insert(auth_rs.clone(), ParseResult::default());
602        parsed_files.insert(db_mod_rs.clone(), ParseResult::default());
603
604        let context = make_context(
605            root.clone(),
606            vec![lib_rs.clone(), auth_rs.clone(), db_mod_rs.clone()],
607            parsed_files,
608        );
609
610        let tree = build_module_tree(&context);
611
612        // Flat style: crate::auth → src/auth.rs
613        assert!(
614            tree.contains_key("crate::auth"),
615            "expected crate::auth in tree, got: {tree:?}"
616        );
617        assert_eq!(tree["crate::auth"], auth_rs);
618
619        // Legacy style: crate::db → src/db/mod.rs
620        assert!(
621            tree.contains_key("crate::db"),
622            "expected crate::db in tree, got: {tree:?}"
623        );
624        assert_eq!(tree["crate::db"], db_mod_rs);
625    }
626
627    // -----------------------------------------------------------------------
628    // mod declarations are not emitted as edges
629    // -----------------------------------------------------------------------
630    #[test]
631    fn mod_declarations_do_not_produce_edges() {
632        let root = PathBuf::from("/project");
633        let lib_rs = root.join("src/lib.rs");
634
635        let context = make_context(root.clone(), vec![lib_rs.clone()], HashMap::new());
636
637        let resolver = RustResolver::new(RustConfig {
638            workspace_members: vec![],
639            edition: None,
640        });
641        let parse_result = ParseResult {
642            imports: vec![mod_import("auth"), mod_import("db")],
643            ..Default::default()
644        };
645
646        let edges = resolver.resolve(&lib_rs, &parse_result, &context).unwrap();
647        assert!(edges.is_empty(), "mod declarations must not produce edges");
648    }
649
650    // -----------------------------------------------------------------------
651    // Unresolvable specifiers are silently skipped
652    // -----------------------------------------------------------------------
653    #[test]
654    fn unresolvable_specifier_is_skipped() {
655        let root = PathBuf::from("/project");
656        let lib_rs = root.join("src/lib.rs");
657
658        let context = make_context(root.clone(), vec![lib_rs.clone()], HashMap::new());
659
660        let resolver = RustResolver::new(RustConfig {
661            workspace_members: vec![],
662            edition: None,
663        });
664        let parse_result = ParseResult {
665            // std paths and external crates are not resolvable by this resolver
666            imports: vec![use_import("std::fmt"), use_import("serde::Serialize")],
667            ..Default::default()
668        };
669
670        let edges = resolver.resolve(&lib_rs, &parse_result, &context).unwrap();
671        assert!(
672            edges.is_empty(),
673            "unresolvable imports must not produce edges"
674        );
675    }
676
677    // -----------------------------------------------------------------------
678    // Recursive module tree from nested mod declarations
679    // -----------------------------------------------------------------------
680    #[test]
681    fn builds_nested_module_tree() {
682        let root = PathBuf::from("/project");
683        let lib_rs = root.join("src/lib.rs");
684        let auth_rs = root.join("src/auth.rs");
685        let validate_rs = root.join("src/auth/validate.rs");
686
687        let mut parsed_files = HashMap::new();
688        // lib.rs declares mod auth
689        parsed_files.insert(
690            lib_rs.clone(),
691            ParseResult {
692                imports: vec![mod_import("auth")],
693                ..Default::default()
694            },
695        );
696        // auth.rs declares mod validate
697        parsed_files.insert(
698            auth_rs.clone(),
699            ParseResult {
700                imports: vec![mod_import("validate")],
701                ..Default::default()
702            },
703        );
704        parsed_files.insert(validate_rs.clone(), ParseResult::default());
705
706        let context = make_context(
707            root.clone(),
708            vec![lib_rs.clone(), auth_rs.clone(), validate_rs.clone()],
709            parsed_files,
710        );
711
712        let tree = build_module_tree(&context);
713
714        assert_eq!(tree.get("crate::auth"), Some(&auth_rs));
715        assert_eq!(tree.get("crate::auth::validate"), Some(&validate_rs));
716    }
717
718    // -----------------------------------------------------------------------
719    // use crate::auth (direct module reference, not a subitem)
720    // -----------------------------------------------------------------------
721    #[test]
722    fn resolves_direct_module_reference() {
723        let root = PathBuf::from("/project");
724        let lib_rs = root.join("src/lib.rs");
725        let auth_rs = root.join("src/auth.rs");
726        let main_rs = root.join("src/main.rs");
727
728        let mut parsed_files = HashMap::new();
729        parsed_files.insert(
730            lib_rs.clone(),
731            ParseResult {
732                imports: vec![mod_import("auth")],
733                ..Default::default()
734            },
735        );
736        parsed_files.insert(auth_rs.clone(), ParseResult::default());
737
738        let context = make_context(
739            root.clone(),
740            vec![lib_rs.clone(), auth_rs.clone()],
741            parsed_files,
742        );
743
744        let resolver = RustResolver::new(RustConfig {
745            workspace_members: vec![],
746            edition: None,
747        });
748        let parse_result = ParseResult {
749            imports: vec![use_import("crate::auth")],
750            ..Default::default()
751        };
752
753        let edges = resolver.resolve(&main_rs, &parse_result, &context).unwrap();
754
755        assert_eq!(edges.len(), 1);
756        assert_eq!(edges[0].kind, EdgeKind::ImportsFrom);
757        assert_eq!(edges[0].target, auth_rs.to_string_lossy());
758    }
759
760    // -----------------------------------------------------------------------
761    // super:: resolution
762    // -----------------------------------------------------------------------
763    #[test]
764    fn resolves_super_path() {
765        let root = PathBuf::from("/project");
766        let lib_rs = root.join("src/lib.rs");
767        let auth_rs = root.join("src/auth.rs");
768        let validate_rs = root.join("src/auth/validate.rs");
769
770        let mut parsed_files = HashMap::new();
771        parsed_files.insert(
772            lib_rs.clone(),
773            ParseResult {
774                imports: vec![mod_import("auth")],
775                ..Default::default()
776            },
777        );
778        parsed_files.insert(
779            auth_rs.clone(),
780            ParseResult {
781                imports: vec![mod_import("validate")],
782                ..Default::default()
783            },
784        );
785        parsed_files.insert(validate_rs.clone(), ParseResult::default());
786
787        let context = make_context(
788            root.clone(),
789            vec![lib_rs.clone(), auth_rs.clone(), validate_rs.clone()],
790            parsed_files,
791        );
792
793        // validate.rs does `use super::something` — resolves to crate::auth → auth.rs
794        let resolver = RustResolver::new(RustConfig {
795            workspace_members: vec![],
796            edition: None,
797        });
798        let parse_result = ParseResult {
799            imports: vec![use_import("super::something")],
800            ..Default::default()
801        };
802
803        // Note: super::something resolves to parent path "crate::auth", which maps to auth_rs.
804        // "something" is just a name in auth module, not a submodule, so no deeper resolution.
805        // But "crate::auth" IS in the module tree.
806        let edges = resolver
807            .resolve(&validate_rs, &parse_result, &context)
808            .unwrap();
809
810        assert_eq!(
811            edges.len(),
812            1,
813            "Expected ImportsFrom edge via super, got: {edges:?}"
814        );
815        assert_eq!(edges[0].kind, EdgeKind::ImportsFrom);
816        assert_eq!(edges[0].source, validate_rs.to_string_lossy());
817        assert_eq!(edges[0].target, auth_rs.to_string_lossy());
818    }
819}
820
821#[cfg(test)]
822mod config_tests {
823    use super::*;
824
825    #[test]
826    fn rust_config_parses_workspace_members() {
827        let dir = tempfile::tempdir().unwrap();
828        std::fs::write(
829            dir.path().join("Cargo.toml"),
830            r#"
831[workspace]
832members = ["crates/foo", "crates/bar"]
833
834[package]
835edition = "2021"
836"#,
837        )
838        .unwrap();
839        let config = RustConfig::load(dir.path());
840        assert_eq!(config.workspace_members, vec!["crates/foo", "crates/bar"]);
841        assert_eq!(config.edition.as_deref(), Some("2021"));
842    }
843
844    #[test]
845    fn rust_config_empty_without_workspace() {
846        let dir = tempfile::tempdir().unwrap();
847        std::fs::write(
848            dir.path().join("Cargo.toml"),
849            r#"
850[package]
851name = "solo"
852edition = "2021"
853"#,
854        )
855        .unwrap();
856        let config = RustConfig::load(dir.path());
857        assert!(config.workspace_members.is_empty());
858    }
859
860    #[test]
861    fn rust_config_empty_without_cargo_toml() {
862        let dir = tempfile::tempdir().unwrap();
863        let config = RustConfig::load(dir.path());
864        assert!(config.workspace_members.is_empty());
865        assert!(config.edition.is_none());
866    }
867}