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
9pub 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
58pub struct RustResolver {
60 _config: RustConfig,
61}
62
63impl RustResolver {
64 pub fn new(config: RustConfig) -> Self {
65 Self { _config: config }
66 }
67}
68
69type ModuleTree = HashMap<String, PathBuf>;
76
77fn 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
90fn 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 if visited.contains(¤t_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 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 let submodule_dir = if is_root_file {
132 parent_dir.clone()
133 } else {
134 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 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
174fn 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
197fn 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 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
220fn 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 let base_module_path = match segments[0] {
238 "self" => {
239 file_to_module_path(project_root, file_path, module_tree)?
241 }
242 "super" => {
243 let current = file_to_module_path(project_root, file_path, module_tree)?;
245 current.rsplit_once("::").map(|(p, _)| p.to_string())?
247 }
248 "crate" => "crate".to_string(),
249 _ => return None,
250 };
251
252 let rest_segments = &segments[1..];
254
255 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 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
284impl 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 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 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#[cfg(test)]
350mod tests {
351 use super::*;
352 use std::collections::HashMap;
353
354 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 fn mod_import(name: &str) -> crate::RawImport {
369 crate::RawImport {
370 specifier: format!("mod::{name}"),
371 ..Default::default()
372 }
373 }
374
375 fn use_import(specifier: &str) -> crate::RawImport {
377 crate::RawImport {
378 specifier: specifier.to_string(),
379 ..Default::default()
380 }
381 }
382
383 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 #[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 #[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 #[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 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 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 #[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 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 #[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 let auth_rs = root.join("src/auth.rs");
590 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 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 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 #[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 #[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 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 #[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 parsed_files.insert(
690 lib_rs.clone(),
691 ParseResult {
692 imports: vec![mod_import("auth")],
693 ..Default::default()
694 },
695 );
696 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 #[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 #[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 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 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}