1use std::collections::HashSet;
13use std::path::Path;
14
15use crate::analysis::parser::import::ImportKind;
16use crate::analysis::parser::StaticFileAnalysis;
17use crate::analysis::resolvers::{FileIndex, ResolverRegistry};
18use crate::analysis::walker::{Language, WalkedFile};
19use crate::graph::EdgeKind;
20
21pub struct Layer0Edges {
23 pub edges: Vec<(String, EdgeKind, String)>,
24 pub unresolved_imports: usize,
26}
27
28pub fn build_edges(
34 files: &[WalkedFile],
35 analyses: &[StaticFileAnalysis],
36 co_change_pairs: &[(String, String, u32)],
37) -> Layer0Edges {
38 build_edges_with_root(files, analyses, co_change_pairs, None)
39}
40
41pub fn build_edges_with_root(
44 files: &[WalkedFile],
45 analyses: &[StaticFileAnalysis],
46 co_change_pairs: &[(String, String, u32)],
47 repo_root: Option<&Path>,
48) -> Layer0Edges {
49 assert_eq!(
50 files.len(),
51 analyses.len(),
52 "build_edges expects one analysis per walked file"
53 );
54
55 let file_set: HashSet<&str> = files.iter().map(|f| f.rel_path.as_str()).collect();
56
57 let derived_root = repo_root.map(|p| p.to_path_buf()).or_else(|| {
59 files.first().and_then(|f| {
60 f.abs_path
61 .to_str()
62 .and_then(|abs| abs.strip_suffix(&f.rel_path))
63 .map(|r| Path::new(r.trim_end_matches('/')).to_path_buf())
64 })
65 });
66
67 let mut file_index = match derived_root {
68 Some(ref root) => {
69 FileIndex::new_with_root(root.clone(), files.iter().map(|f| f.rel_path.clone()))
70 }
71 None => FileIndex::new(files.iter().map(|f| f.rel_path.clone())),
72 };
73
74 if let Some(ref root) = derived_root {
76 let crate_roots = detect_rust_crate_roots(root, &file_index);
77 if !crate_roots.is_empty() {
78 file_index.set_crate_roots(crate_roots);
79 }
80 let members = detect_workspace_members(root);
81 if !members.is_empty() {
82 file_index.set_workspace_members(members);
83 }
84 }
85
86 let scala_roots = detect_scala_source_roots(files);
88 if !scala_roots.is_empty() {
89 file_index.set_scala_source_roots(scala_roots);
90 }
91
92 let (ruby_autoload, ruby_lib) = detect_ruby_roots(files);
94 if !ruby_autoload.is_empty() {
95 file_index.set_ruby_autoload_roots(ruby_autoload);
96 }
97 if !ruby_lib.is_empty() {
98 file_index.set_ruby_lib_roots(ruby_lib);
99 }
100
101 let registry = ResolverRegistry::new();
102
103 let mut edges: Vec<(String, EdgeKind, String)> = Vec::new();
104 let mut unresolved_imports = 0usize;
105
106 for (file, analysis) in files.iter().zip(analyses.iter()) {
108 if analysis.imports.is_empty() {
110 continue;
111 }
112
113 let from_key = file_key(&file.rel_path);
114
115 for import_stmt in &analysis.imports {
116 if import_stmt.kind == ImportKind::External {
119 if file.language == Language::Rust && file_index.has_workspace_members() {
120 if let Some(target_rel) = crate::analysis::resolvers::rust::resolve_cross_crate(
121 &import_stmt.path,
122 &file_index,
123 ) {
124 let to_key = file_key(&target_rel);
125 if from_key != to_key {
126 edges.push((from_key.clone(), EdgeKind::Imports, to_key));
127 }
128 }
129 }
130 if matches!(file.language, Language::C | Language::Cpp) {
134 let resolved = match file.language {
135 Language::Cpp => crate::analysis::resolvers::cpp::resolve_angle_bracket(
136 &import_stmt.path,
137 &file.rel_path,
138 &file_index,
139 ),
140 _ => crate::analysis::resolvers::c::resolve_angle_bracket(
141 &import_stmt.path,
142 &file.rel_path,
143 &file_index,
144 ),
145 };
146 if let Some(target_rel) = resolved {
147 let to_key = file_key(&target_rel);
148 if from_key != to_key {
149 edges.push((from_key.clone(), EdgeKind::Imports, to_key));
150 }
151 }
152 }
153 continue;
154 }
155
156 if let Some(target_rel) =
157 registry.resolve(import_stmt, &file.rel_path, file.language, &file_index)
158 {
159 let to_key = file_key(&target_rel);
160 if from_key != to_key {
162 edges.push((from_key.clone(), EdgeKind::Imports, to_key));
163 }
164 } else {
165 unresolved_imports += 1;
166 }
167 }
168 }
169
170 for (a, b, _count) in co_change_pairs {
174 if file_set.contains(a.as_str()) && file_set.contains(b.as_str()) {
175 let key_a = file_key(a);
176 let key_b = file_key(b);
177 edges.push((key_a.clone(), EdgeKind::CoChanges, key_b.clone()));
178 edges.push((key_b, EdgeKind::CoChanges, key_a));
179 }
180 }
181
182 let mut seen = std::collections::HashSet::new();
187 edges.retain(|e| seen.insert(e.clone()));
188
189 Layer0Edges {
190 edges,
191 unresolved_imports,
192 }
193}
194
195pub fn detect_rust_crate_roots(repo_root: &Path, file_index: &FileIndex) -> Vec<String> {
201 let cargo_path = repo_root.join("Cargo.toml");
202 let content = match std::fs::read_to_string(&cargo_path) {
203 Ok(c) => c,
204 Err(_) => return Vec::new(),
205 };
206
207 let doc = match content.parse::<toml_edit::DocumentMut>() {
208 Ok(d) => d,
209 Err(_) => return Vec::new(),
210 };
211
212 let mut roots = Vec::new();
213
214 if let Some(workspace) = doc.get("workspace").and_then(|w| w.as_table()) {
216 if let Some(members) = workspace.get("members").and_then(|m| m.as_array()) {
217 for member in members.iter() {
218 if let Some(pattern) = member.as_str() {
219 expand_workspace_member(repo_root, pattern, &mut roots);
220 }
221 }
222 }
223 }
224
225 if !roots.is_empty() {
227 if file_index.contains("src/lib.rs") || file_index.contains("src/main.rs") {
228 roots.push("src/".to_string());
229 }
230 return roots;
231 }
232
233 if doc.get("package").is_some()
235 && (file_index.contains("src/lib.rs") || file_index.contains("src/main.rs"))
236 {
237 roots.push("src/".to_string());
238 }
239
240 roots
241}
242
243fn expand_workspace_member(repo_root: &Path, pattern: &str, roots: &mut Vec<String>) {
245 if pattern.contains('*') {
246 let base_dir = repo_root.join(pattern.split('*').next().unwrap_or(""));
248 if let Ok(entries) = std::fs::read_dir(&base_dir) {
249 for entry in entries.flatten() {
250 let path = entry.path();
251 if path.is_dir() && path.join("src").is_dir() {
252 if let Ok(rel) = path.strip_prefix(repo_root) {
253 let root = format!("{}/src/", rel.to_string_lossy().replace('\\', "/"));
254 roots.push(root);
255 }
256 }
257 }
258 }
259 } else {
260 let member_dir = repo_root.join(pattern);
262 if member_dir.join("src").is_dir() {
263 let root = format!("{}/src/", pattern.trim_end_matches('/'));
264 roots.push(root);
265 }
266 }
267}
268
269pub fn detect_workspace_members(repo_root: &Path) -> std::collections::HashMap<String, String> {
274 let mut members = std::collections::HashMap::new();
275
276 let cargo_path = repo_root.join("Cargo.toml");
277 let content = match std::fs::read_to_string(&cargo_path) {
278 Ok(c) => c,
279 Err(_) => return members,
280 };
281 let doc = match content.parse::<toml_edit::DocumentMut>() {
282 Ok(d) => d,
283 Err(_) => return members,
284 };
285
286 let workspace = match doc.get("workspace").and_then(|w| w.as_table()) {
287 Some(w) => w,
288 None => return members,
289 };
290 let member_patterns = match workspace.get("members").and_then(|m| m.as_array()) {
291 Some(a) => a,
292 None => return members,
293 };
294
295 let mut member_dirs: Vec<std::path::PathBuf> = Vec::new();
297 for member in member_patterns.iter() {
298 if let Some(pattern) = member.as_str() {
299 collect_member_dirs(repo_root, pattern, &mut member_dirs);
300 }
301 }
302
303 if doc.get("package").is_some() {
305 member_dirs.push(repo_root.to_path_buf());
306 }
307
308 for dir in &member_dirs {
310 let member_cargo = dir.join("Cargo.toml");
311 let member_content = match std::fs::read_to_string(&member_cargo) {
312 Ok(c) => c,
313 Err(_) => continue,
314 };
315 let member_doc = match member_content.parse::<toml_edit::DocumentMut>() {
316 Ok(d) => d,
317 Err(_) => continue,
318 };
319 let name = match member_doc
320 .get("package")
321 .and_then(|p| p.get("name"))
322 .and_then(|n| n.as_str())
323 {
324 Some(n) => n,
325 None => continue,
326 };
327
328 let crate_root = if dir == repo_root {
330 "src/".to_string()
331 } else if let Ok(rel) = dir.strip_prefix(repo_root) {
332 format!("{}/src/", rel.to_string_lossy().replace('\\', "/"))
333 } else {
334 continue;
335 };
336
337 let snake_name = name.replace('-', "_");
339 members.insert(snake_name, crate_root);
340 }
341
342 members
343}
344
345fn collect_member_dirs(repo_root: &Path, pattern: &str, dirs: &mut Vec<std::path::PathBuf>) {
347 if pattern.contains('*') {
348 let base_dir = repo_root.join(pattern.split('*').next().unwrap_or(""));
349 if let Ok(entries) = std::fs::read_dir(&base_dir) {
350 for entry in entries.flatten() {
351 let path = entry.path();
352 if path.is_dir() {
353 dirs.push(path);
354 }
355 }
356 }
357 } else {
358 let dir = repo_root.join(pattern);
359 if dir.is_dir() {
360 dirs.push(dir);
361 }
362 }
363}
364
365fn detect_scala_source_roots(files: &[WalkedFile]) -> Vec<String> {
372 const SCALA_PATTERNS: &[&str] = &[
373 "src/main/scala/",
374 "src/test/scala/",
375 "src/main/scala-2.13/",
376 "src/main/scala-2.12/",
377 "src/main/scala-3/",
378 "src/test/scala-2.13/",
379 "src/test/scala-3/",
380 ];
381
382 let mut roots: HashSet<String> = HashSet::new();
383
384 for file in files {
385 if file.language != Language::Scala {
386 continue;
387 }
388 for pattern in SCALA_PATTERNS {
389 if let Some(pos) = file.rel_path.find(pattern) {
390 let root = &file.rel_path[..pos + pattern.len()];
391 roots.insert(root.to_string());
392 }
393 }
394 }
395
396 let mut result: Vec<String> = roots.into_iter().collect();
397 result.sort(); result
399}
400
401fn detect_ruby_roots(files: &[WalkedFile]) -> (Vec<String>, Vec<String>) {
409 let mut autoload: HashSet<String> = HashSet::new();
410 let mut lib: HashSet<String> = HashSet::new();
411
412 for file in files {
413 if file.language != Language::Ruby {
414 continue;
415 }
416 let path = &file.rel_path;
417
418 if let Some(app_idx) = find_segment(path, "app/") {
426 let after_app = &path[app_idx + 4..]; if let Some(slash) = after_app.find('/') {
428 let prefix = &path[..app_idx];
429 let subdir = &after_app[..slash];
430 let root = format!("{prefix}app/{subdir}/");
431
432 let rest = &after_app[slash + 1..];
434 if rest.starts_with("concerns/") {
435 autoload.insert(format!("{root}concerns/"));
436 }
437
438 autoload.insert(root);
439 }
440 }
441
442 if let Some(lib_idx) = find_segment(path, "lib/") {
446 let prefix = &path[..lib_idx];
447 lib.insert(format!("{prefix}lib/"));
448 }
449 }
450
451 let mut autoload_vec: Vec<String> = autoload.into_iter().collect();
452 autoload_vec.sort();
453 let mut lib_vec: Vec<String> = lib.into_iter().collect();
454 lib_vec.sort();
455 (autoload_vec, lib_vec)
456}
457
458fn find_segment(path: &str, segment: &str) -> Option<usize> {
463 if path.starts_with(segment) {
464 return Some(0);
465 }
466 let needle = format!("/{segment}");
467 path.find(&needle).map(|pos| pos + 1)
468}
469
470fn file_key(rel_path: &str) -> String {
472 format!("file:{rel_path}")
473}
474
475#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::analysis::parser::import::ImportStatement;
481 use crate::analysis::parser::StaticFileAnalysis;
482 use crate::analysis::walker::Language;
483
484 fn walked(rel_path: &str, lang: Language) -> WalkedFile {
485 WalkedFile {
486 abs_path: std::path::PathBuf::from(format!("/repo/{rel_path}")),
487 rel_path: rel_path.to_string(),
488 language: lang,
489 size_bytes: 100,
490 mtime_secs: 0,
491 }
492 }
493
494 fn classify_import(path: &str, lang: Language) -> ImportStatement {
496 let kind = match lang {
497 Language::Rust => {
498 if path.ends_with("::*") {
499 if path.starts_with("crate::")
500 || path.starts_with("self::")
501 || path.starts_with("super::")
502 {
503 ImportKind::Wildcard
504 } else {
505 ImportKind::External
506 }
507 } else if path.starts_with("crate::")
508 || path.starts_with("self::")
509 || path.starts_with("super::")
510 {
511 ImportKind::Normal
512 } else {
513 ImportKind::External
514 }
515 }
516 Language::TypeScript | Language::JavaScript => {
517 if path.starts_with('.') {
518 ImportKind::Relative
519 } else {
520 ImportKind::External
521 }
522 }
523 Language::Python => {
524 if path.starts_with('.') {
525 ImportKind::Relative
526 } else {
527 ImportKind::Normal
528 }
529 }
530 _ => ImportKind::Normal,
531 };
532 ImportStatement::new(path, kind, 0)
533 }
534
535 fn analysis(path: &str, lang: Language, imports: &[&str]) -> StaticFileAnalysis {
536 StaticFileAnalysis {
537 path: path.to_string(),
538 language: lang,
539 entry_points: vec![],
540 exported_types: vec![],
541 imports: imports.iter().map(|s| classify_import(s, lang)).collect(),
542 todos: vec![],
543 unsafe_count: 0,
544 unwrap_count: 0,
545 panic_count: 0,
546 branch_count: 0,
547 module_doc: None,
548 content_hash: None,
549 line_count: 0,
550 }
551 }
552
553 #[test]
556 fn rust_crate_import_resolves_to_file() {
557 let files = vec![
558 walked("src/lib.rs", Language::Rust),
559 walked("src/utils.rs", Language::Rust),
560 ];
561 let analyses = vec![
562 analysis("src/lib.rs", Language::Rust, &["crate::utils"]),
563 analysis("src/utils.rs", Language::Rust, &[]),
564 ];
565
566 let result = build_edges(&files, &analyses, &[]);
567 assert_eq!(result.edges.len(), 1);
568 assert_eq!(result.edges[0].0, "file:src/lib.rs");
569 assert_eq!(result.edges[0].1, EdgeKind::Imports);
570 assert_eq!(result.edges[0].2, "file:src/utils.rs");
571 assert_eq!(result.unresolved_imports, 0);
572 }
573
574 #[test]
575 fn repeated_imports_of_same_target_yield_one_edge() {
576 let files = vec![
577 walked("src/lib.rs", Language::Rust),
578 walked("src/utils.rs", Language::Rust),
579 ];
580 let analyses = vec![
581 analysis(
582 "src/lib.rs",
583 Language::Rust,
584 &["crate::utils", "crate::utils"],
585 ),
586 analysis("src/utils.rs", Language::Rust, &[]),
587 ];
588
589 let result = build_edges(&files, &analyses, &[]);
590 assert_eq!(result.edges.len(), 1, "got {:?}", result.edges);
591 }
592
593 #[test]
594 fn rust_crate_import_resolves_to_mod_rs() {
595 let files = vec![
596 walked("src/lib.rs", Language::Rust),
597 walked("src/store/mod.rs", Language::Rust),
598 ];
599 let analyses = vec![
600 analysis("src/lib.rs", Language::Rust, &["crate::store"]),
601 analysis("src/store/mod.rs", Language::Rust, &[]),
602 ];
603
604 let result = build_edges(&files, &analyses, &[]);
605 assert_eq!(result.edges.len(), 1);
606 assert_eq!(result.edges[0].2, "file:src/store/mod.rs");
607 }
608
609 #[test]
610 fn rust_self_import_resolves_relative_module() {
611 let files = vec![
612 walked("src/store/mod.rs", Language::Rust),
613 walked("src/store/helpers.rs", Language::Rust),
614 ];
615 let analyses = vec![
616 analysis("src/store/mod.rs", Language::Rust, &["self::helpers"]),
617 analysis("src/store/helpers.rs", Language::Rust, &[]),
618 ];
619
620 let result = build_edges(&files, &analyses, &[]);
621 assert_eq!(result.edges.len(), 1);
622 assert_eq!(result.edges[0].2, "file:src/store/helpers.rs");
623 assert_eq!(result.unresolved_imports, 0);
624 }
625
626 #[test]
627 fn rust_super_import_resolves_parent_module() {
628 let files = vec![
629 walked("src/store/db.rs", Language::Rust),
630 walked("src/store/helpers.rs", Language::Rust),
631 ];
632 let analyses = vec![
633 analysis("src/store/db.rs", Language::Rust, &["super::helpers"]),
634 analysis("src/store/helpers.rs", Language::Rust, &[]),
635 ];
636
637 let result = build_edges(&files, &analyses, &[]);
638 assert_eq!(result.edges.len(), 1);
639 assert_eq!(result.edges[0].2, "file:src/store/helpers.rs");
640 assert_eq!(result.unresolved_imports, 0);
641 }
642
643 #[test]
644 fn rust_super_import_unresolved_when_target_missing() {
645 let files = vec![walked("src/store/db.rs", Language::Rust)];
646 let analyses = vec![analysis(
647 "src/store/db.rs",
648 Language::Rust,
649 &["super::helpers"],
650 )];
651
652 let result = build_edges(&files, &analyses, &[]);
653 assert_eq!(result.edges.len(), 0);
654 assert_eq!(result.unresolved_imports, 1);
655 }
656
657 #[test]
658 fn rust_nested_crate_import() {
659 let files = vec![
660 walked("src/main.rs", Language::Rust),
661 walked("src/store/db.rs", Language::Rust),
662 ];
663 let analyses = vec![
664 analysis("src/main.rs", Language::Rust, &["crate::store::db"]),
665 analysis("src/store/db.rs", Language::Rust, &[]),
666 ];
667
668 let result = build_edges(&files, &analyses, &[]);
669 assert_eq!(result.edges.len(), 1);
670 assert_eq!(result.edges[0].2, "file:src/store/db.rs");
671 }
672
673 #[test]
674 fn rust_std_import_skipped() {
675 let files = vec![walked("src/lib.rs", Language::Rust)];
676 let analyses = vec![analysis(
677 "src/lib.rs",
678 Language::Rust,
679 &["std::collections::HashMap"],
680 )];
681
682 let result = build_edges(&files, &analyses, &[]);
683 assert_eq!(result.edges.len(), 0);
684 assert_eq!(result.unresolved_imports, 0);
686 }
687
688 #[test]
689 fn rust_external_crate_import_skipped() {
690 let files = vec![walked("src/lib.rs", Language::Rust)];
691 let analyses = vec![analysis(
692 "src/lib.rs",
693 Language::Rust,
694 &["anyhow::Result", "serde::Serialize"],
695 )];
696
697 let result = build_edges(&files, &analyses, &[]);
698 assert_eq!(result.edges.len(), 0);
699 assert_eq!(result.unresolved_imports, 0);
701 }
702
703 #[test]
704 fn rust_no_self_edges() {
705 let files = vec![walked("src/store.rs", Language::Rust)];
706 let analyses = vec![analysis("src/store.rs", Language::Rust, &["crate::store"])];
707
708 let result = build_edges(&files, &analyses, &[]);
709 assert_eq!(result.edges.len(), 0);
710 }
711
712 #[test]
715 fn python_absolute_import_resolves() {
716 let files = vec![
717 walked("app/main.py", Language::Python),
718 walked("app/utils.py", Language::Python),
719 ];
720 let analyses = vec![
721 analysis("app/main.py", Language::Python, &["app.utils"]),
722 analysis("app/utils.py", Language::Python, &[]),
723 ];
724
725 let result = build_edges(&files, &analyses, &[]);
726 assert_eq!(result.edges.len(), 1);
727 assert_eq!(result.edges[0].2, "file:app/utils.py");
728 }
729
730 #[test]
731 fn python_relative_import_resolves() {
732 let files = vec![
733 walked("app/main.py", Language::Python),
734 walked("app/helpers.py", Language::Python),
735 ];
736 let analyses = vec![
737 analysis("app/main.py", Language::Python, &[".helpers"]),
738 analysis("app/helpers.py", Language::Python, &[]),
739 ];
740
741 let result = build_edges(&files, &analyses, &[]);
742 assert_eq!(result.edges.len(), 1);
743 assert_eq!(result.edges[0].2, "file:app/helpers.py");
744 }
745
746 #[test]
747 fn python_package_init_resolves() {
748 let files = vec![
749 walked("main.py", Language::Python),
750 walked("pkg/__init__.py", Language::Python),
751 ];
752 let analyses = vec![
753 analysis("main.py", Language::Python, &["pkg"]),
754 analysis("pkg/__init__.py", Language::Python, &[]),
755 ];
756
757 let result = build_edges(&files, &analyses, &[]);
758 assert_eq!(result.edges.len(), 1);
759 assert_eq!(result.edges[0].2, "file:pkg/__init__.py");
760 }
761
762 #[test]
763 fn rust_unknown_import_returns_none() {
764 let files = vec![walked("src/lib.rs", Language::Rust)];
765 let analyses = vec![analysis(
766 "src/lib.rs",
767 Language::Rust,
768 &["crate::nonexistent"],
769 )];
770
771 let result = build_edges(&files, &analyses, &[]);
772 assert_eq!(result.edges.len(), 0);
773 assert_eq!(result.unresolved_imports, 1);
774 }
775
776 #[test]
777 fn python_unknown_import_returns_none() {
778 let files = vec![walked("app/main.py", Language::Python)];
779 let analyses = vec![analysis(
780 "app/main.py",
781 Language::Python,
782 &["app.nonexistent"],
783 )];
784
785 let result = build_edges(&files, &analyses, &[]);
786 assert_eq!(result.edges.len(), 0);
787 assert_eq!(result.unresolved_imports, 1);
788 }
789
790 #[test]
793 fn ts_relative_import_resolves() {
794 let files = vec![
795 walked("src/app.ts", Language::TypeScript),
796 walked("src/utils.ts", Language::TypeScript),
797 ];
798 let analyses = vec![
799 analysis("src/app.ts", Language::TypeScript, &["./utils"]),
800 analysis("src/utils.ts", Language::TypeScript, &[]),
801 ];
802
803 let result = build_edges(&files, &analyses, &[]);
804 assert_eq!(result.edges.len(), 1);
805 assert_eq!(result.edges[0].2, "file:src/utils.ts");
806 }
807
808 #[test]
809 fn ts_relative_import_parent_dir() {
810 let files = vec![
811 walked("src/components/button.tsx", Language::TypeScript),
812 walked("src/utils.ts", Language::TypeScript),
813 ];
814 let analyses = vec![
815 analysis(
816 "src/components/button.tsx",
817 Language::TypeScript,
818 &["../utils"],
819 ),
820 analysis("src/utils.ts", Language::TypeScript, &[]),
821 ];
822
823 let result = build_edges(&files, &analyses, &[]);
824 assert_eq!(result.edges.len(), 1);
825 assert_eq!(result.edges[0].2, "file:src/utils.ts");
826 }
827
828 #[test]
829 fn ts_index_file_resolves() {
830 let files = vec![
831 walked("src/app.ts", Language::TypeScript),
832 walked("src/components/index.ts", Language::TypeScript),
833 ];
834 let analyses = vec![
835 analysis("src/app.ts", Language::TypeScript, &["./components"]),
836 analysis("src/components/index.ts", Language::TypeScript, &[]),
837 ];
838
839 let result = build_edges(&files, &analyses, &[]);
840 assert_eq!(result.edges.len(), 1);
841 assert_eq!(result.edges[0].2, "file:src/components/index.ts");
842 }
843
844 #[test]
845 fn ts_bare_specifier_skipped() {
846 let files = vec![walked("src/app.ts", Language::TypeScript)];
847 let analyses = vec![analysis(
848 "src/app.ts",
849 Language::TypeScript,
850 &["react", "@tanstack/query"],
851 )];
852
853 let result = build_edges(&files, &analyses, &[]);
854 assert_eq!(result.edges.len(), 0);
855 assert_eq!(result.unresolved_imports, 0);
857 }
858
859 #[test]
860 fn js_relative_import_resolves_to_js() {
861 let files = vec![
862 walked("lib/index.js", Language::JavaScript),
863 walked("lib/helpers.js", Language::JavaScript),
864 ];
865 let analyses = vec![
866 analysis("lib/index.js", Language::JavaScript, &["./helpers"]),
867 analysis("lib/helpers.js", Language::JavaScript, &[]),
868 ];
869
870 let result = build_edges(&files, &analyses, &[]);
871 assert_eq!(result.edges.len(), 1);
872 assert_eq!(result.edges[0].2, "file:lib/helpers.js");
873 }
874
875 #[test]
878 fn co_change_creates_bidirectional_edges() {
879 let files = vec![
880 walked("src/a.rs", Language::Rust),
881 walked("src/b.rs", Language::Rust),
882 ];
883 let analyses = vec![
884 analysis("src/a.rs", Language::Rust, &[]),
885 analysis("src/b.rs", Language::Rust, &[]),
886 ];
887 let pairs = vec![("src/a.rs".to_string(), "src/b.rs".to_string(), 5)];
888
889 let result = build_edges(&files, &analyses, &pairs);
890 assert_eq!(result.edges.len(), 2);
891
892 let has_a_to_b = result.edges.iter().any(|(from, kind, to)| {
893 from == "file:src/a.rs" && *kind == EdgeKind::CoChanges && to == "file:src/b.rs"
894 });
895 let has_b_to_a = result.edges.iter().any(|(from, kind, to)| {
896 from == "file:src/b.rs" && *kind == EdgeKind::CoChanges && to == "file:src/a.rs"
897 });
898 assert!(has_a_to_b, "missing a→b edge");
899 assert!(has_b_to_a, "missing b→a edge");
900 }
901
902 #[test]
903 fn co_change_skips_unknown_files() {
904 let files = vec![walked("src/a.rs", Language::Rust)];
905 let analyses = vec![analysis("src/a.rs", Language::Rust, &[])];
906 let pairs = vec![("src/a.rs".to_string(), "src/b.rs".to_string(), 3)];
908
909 let result = build_edges(&files, &analyses, &pairs);
910 assert_eq!(result.edges.len(), 0);
911 }
912
913 #[test]
916 fn imports_and_co_changes_combined() {
917 let files = vec![
918 walked("src/lib.rs", Language::Rust),
919 walked("src/store.rs", Language::Rust),
920 walked("src/search.rs", Language::Rust),
921 ];
922 let analyses = vec![
923 analysis(
924 "src/lib.rs",
925 Language::Rust,
926 &["crate::store", "crate::search"],
927 ),
928 analysis("src/store.rs", Language::Rust, &[]),
929 analysis("src/search.rs", Language::Rust, &[]),
930 ];
931 let pairs = vec![("src/search.rs".to_string(), "src/store.rs".to_string(), 4)];
932
933 let result = build_edges(&files, &analyses, &pairs);
934
935 let import_count = result
936 .edges
937 .iter()
938 .filter(|(_, k, _)| *k == EdgeKind::Imports)
939 .count();
940 let co_change_count = result
941 .edges
942 .iter()
943 .filter(|(_, k, _)| *k == EdgeKind::CoChanges)
944 .count();
945
946 assert_eq!(import_count, 2); assert_eq!(co_change_count, 2); }
949
950 #[test]
951 fn empty_inputs_produce_no_edges() {
952 let result = build_edges(&[], &[], &[]);
953 assert_eq!(result.edges.len(), 0);
954 assert_eq!(result.unresolved_imports, 0);
955 }
956
957 fn analysis_with_imports(
961 path: &str,
962 lang: Language,
963 imports: Vec<ImportStatement>,
964 ) -> StaticFileAnalysis {
965 StaticFileAnalysis {
966 path: path.to_string(),
967 language: lang,
968 entry_points: vec![],
969 exported_types: vec![],
970 imports,
971 todos: vec![],
972 unsafe_count: 0,
973 unwrap_count: 0,
974 panic_count: 0,
975 branch_count: 0,
976 module_doc: None,
977 content_hash: None,
978 line_count: 0,
979 }
980 }
981
982 #[test]
983 fn cpp_angle_bracket_internal_include_resolves() {
984 let files = vec![
987 walked("tests/test.cpp", Language::Cpp),
988 walked("include/nlohmann/json.hpp", Language::Cpp),
989 ];
990 let analyses = vec![
991 analysis_with_imports(
992 "tests/test.cpp",
993 Language::Cpp,
994 vec![ImportStatement::new(
995 "nlohmann/json.hpp",
996 ImportKind::External,
997 1,
998 )],
999 ),
1000 analysis_with_imports("include/nlohmann/json.hpp", Language::Cpp, vec![]),
1001 ];
1002
1003 let result = build_edges(&files, &analyses, &[]);
1004 assert_eq!(
1005 result.edges.len(),
1006 1,
1007 "angle-bracket include that resolves to a repo file should produce an edge"
1008 );
1009 assert_eq!(result.edges[0].2, "file:include/nlohmann/json.hpp");
1010 }
1011
1012 #[test]
1013 fn cpp_angle_bracket_external_stays_skipped() {
1014 let files = vec![walked("src/main.cpp", Language::Cpp)];
1016 let analyses = vec![analysis_with_imports(
1017 "src/main.cpp",
1018 Language::Cpp,
1019 vec![ImportStatement::new("vector", ImportKind::External, 1)],
1020 )];
1021
1022 let result = build_edges(&files, &analyses, &[]);
1023 assert_eq!(result.edges.len(), 0);
1024 assert_eq!(result.unresolved_imports, 0);
1026 }
1027
1028 #[test]
1029 fn cpp_quoted_include_unchanged() {
1030 let files = vec![
1032 walked("src/main.cpp", Language::Cpp),
1033 walked("src/helper.h", Language::Cpp),
1034 ];
1035 let analyses = vec![
1036 analysis_with_imports(
1037 "src/main.cpp",
1038 Language::Cpp,
1039 vec![ImportStatement::new("helper.h", ImportKind::Relative, 1)],
1040 ),
1041 analysis_with_imports("src/helper.h", Language::Cpp, vec![]),
1042 ];
1043
1044 let result = build_edges(&files, &analyses, &[]);
1045 assert_eq!(result.edges.len(), 1);
1046 assert_eq!(result.edges[0].2, "file:src/helper.h");
1047 }
1048}