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 Layer0Edges {
183 edges,
184 unresolved_imports,
185 }
186}
187
188pub fn detect_rust_crate_roots(repo_root: &Path, file_index: &FileIndex) -> Vec<String> {
194 let cargo_path = repo_root.join("Cargo.toml");
195 let content = match std::fs::read_to_string(&cargo_path) {
196 Ok(c) => c,
197 Err(_) => return Vec::new(),
198 };
199
200 let doc = match content.parse::<toml_edit::DocumentMut>() {
201 Ok(d) => d,
202 Err(_) => return Vec::new(),
203 };
204
205 let mut roots = Vec::new();
206
207 if let Some(workspace) = doc.get("workspace").and_then(|w| w.as_table()) {
209 if let Some(members) = workspace.get("members").and_then(|m| m.as_array()) {
210 for member in members.iter() {
211 if let Some(pattern) = member.as_str() {
212 expand_workspace_member(repo_root, pattern, &mut roots);
213 }
214 }
215 }
216 }
217
218 if !roots.is_empty() {
220 if file_index.contains("src/lib.rs") || file_index.contains("src/main.rs") {
221 roots.push("src/".to_string());
222 }
223 return roots;
224 }
225
226 if doc.get("package").is_some()
228 && (file_index.contains("src/lib.rs") || file_index.contains("src/main.rs"))
229 {
230 roots.push("src/".to_string());
231 }
232
233 roots
234}
235
236fn expand_workspace_member(repo_root: &Path, pattern: &str, roots: &mut Vec<String>) {
238 if pattern.contains('*') {
239 let base_dir = repo_root.join(pattern.split('*').next().unwrap_or(""));
241 if let Ok(entries) = std::fs::read_dir(&base_dir) {
242 for entry in entries.flatten() {
243 let path = entry.path();
244 if path.is_dir() && path.join("src").is_dir() {
245 if let Ok(rel) = path.strip_prefix(repo_root) {
246 let root = format!("{}/src/", rel.to_string_lossy().replace('\\', "/"));
247 roots.push(root);
248 }
249 }
250 }
251 }
252 } else {
253 let member_dir = repo_root.join(pattern);
255 if member_dir.join("src").is_dir() {
256 let root = format!("{}/src/", pattern.trim_end_matches('/'));
257 roots.push(root);
258 }
259 }
260}
261
262pub fn detect_workspace_members(repo_root: &Path) -> std::collections::HashMap<String, String> {
267 let mut members = std::collections::HashMap::new();
268
269 let cargo_path = repo_root.join("Cargo.toml");
270 let content = match std::fs::read_to_string(&cargo_path) {
271 Ok(c) => c,
272 Err(_) => return members,
273 };
274 let doc = match content.parse::<toml_edit::DocumentMut>() {
275 Ok(d) => d,
276 Err(_) => return members,
277 };
278
279 let workspace = match doc.get("workspace").and_then(|w| w.as_table()) {
280 Some(w) => w,
281 None => return members,
282 };
283 let member_patterns = match workspace.get("members").and_then(|m| m.as_array()) {
284 Some(a) => a,
285 None => return members,
286 };
287
288 let mut member_dirs: Vec<std::path::PathBuf> = Vec::new();
290 for member in member_patterns.iter() {
291 if let Some(pattern) = member.as_str() {
292 collect_member_dirs(repo_root, pattern, &mut member_dirs);
293 }
294 }
295
296 if doc.get("package").is_some() {
298 member_dirs.push(repo_root.to_path_buf());
299 }
300
301 for dir in &member_dirs {
303 let member_cargo = dir.join("Cargo.toml");
304 let member_content = match std::fs::read_to_string(&member_cargo) {
305 Ok(c) => c,
306 Err(_) => continue,
307 };
308 let member_doc = match member_content.parse::<toml_edit::DocumentMut>() {
309 Ok(d) => d,
310 Err(_) => continue,
311 };
312 let name = match member_doc
313 .get("package")
314 .and_then(|p| p.get("name"))
315 .and_then(|n| n.as_str())
316 {
317 Some(n) => n,
318 None => continue,
319 };
320
321 let crate_root = if dir == repo_root {
323 "src/".to_string()
324 } else if let Ok(rel) = dir.strip_prefix(repo_root) {
325 format!("{}/src/", rel.to_string_lossy().replace('\\', "/"))
326 } else {
327 continue;
328 };
329
330 let snake_name = name.replace('-', "_");
332 members.insert(snake_name, crate_root);
333 }
334
335 members
336}
337
338fn collect_member_dirs(repo_root: &Path, pattern: &str, dirs: &mut Vec<std::path::PathBuf>) {
340 if pattern.contains('*') {
341 let base_dir = repo_root.join(pattern.split('*').next().unwrap_or(""));
342 if let Ok(entries) = std::fs::read_dir(&base_dir) {
343 for entry in entries.flatten() {
344 let path = entry.path();
345 if path.is_dir() {
346 dirs.push(path);
347 }
348 }
349 }
350 } else {
351 let dir = repo_root.join(pattern);
352 if dir.is_dir() {
353 dirs.push(dir);
354 }
355 }
356}
357
358fn detect_scala_source_roots(files: &[WalkedFile]) -> Vec<String> {
365 const SCALA_PATTERNS: &[&str] = &[
366 "src/main/scala/",
367 "src/test/scala/",
368 "src/main/scala-2.13/",
369 "src/main/scala-2.12/",
370 "src/main/scala-3/",
371 "src/test/scala-2.13/",
372 "src/test/scala-3/",
373 ];
374
375 let mut roots: HashSet<String> = HashSet::new();
376
377 for file in files {
378 if file.language != Language::Scala {
379 continue;
380 }
381 for pattern in SCALA_PATTERNS {
382 if let Some(pos) = file.rel_path.find(pattern) {
383 let root = &file.rel_path[..pos + pattern.len()];
384 roots.insert(root.to_string());
385 }
386 }
387 }
388
389 let mut result: Vec<String> = roots.into_iter().collect();
390 result.sort(); result
392}
393
394fn detect_ruby_roots(files: &[WalkedFile]) -> (Vec<String>, Vec<String>) {
402 let mut autoload: HashSet<String> = HashSet::new();
403 let mut lib: HashSet<String> = HashSet::new();
404
405 for file in files {
406 if file.language != Language::Ruby {
407 continue;
408 }
409 let path = &file.rel_path;
410
411 if let Some(app_idx) = find_segment(path, "app/") {
419 let after_app = &path[app_idx + 4..]; if let Some(slash) = after_app.find('/') {
421 let prefix = &path[..app_idx];
422 let subdir = &after_app[..slash];
423 let root = format!("{prefix}app/{subdir}/");
424
425 let rest = &after_app[slash + 1..];
427 if rest.starts_with("concerns/") {
428 autoload.insert(format!("{root}concerns/"));
429 }
430
431 autoload.insert(root);
432 }
433 }
434
435 if let Some(lib_idx) = find_segment(path, "lib/") {
439 let prefix = &path[..lib_idx];
440 lib.insert(format!("{prefix}lib/"));
441 }
442 }
443
444 let mut autoload_vec: Vec<String> = autoload.into_iter().collect();
445 autoload_vec.sort();
446 let mut lib_vec: Vec<String> = lib.into_iter().collect();
447 lib_vec.sort();
448 (autoload_vec, lib_vec)
449}
450
451fn find_segment(path: &str, segment: &str) -> Option<usize> {
456 if path.starts_with(segment) {
457 return Some(0);
458 }
459 let needle = format!("/{segment}");
460 path.find(&needle).map(|pos| pos + 1)
461}
462
463fn file_key(rel_path: &str) -> String {
465 format!("file:{rel_path}")
466}
467
468#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::analysis::parser::import::ImportStatement;
474 use crate::analysis::parser::StaticFileAnalysis;
475 use crate::analysis::walker::Language;
476
477 fn walked(rel_path: &str, lang: Language) -> WalkedFile {
478 WalkedFile {
479 abs_path: std::path::PathBuf::from(format!("/repo/{rel_path}")),
480 rel_path: rel_path.to_string(),
481 language: lang,
482 size_bytes: 100,
483 mtime_secs: 0,
484 }
485 }
486
487 fn classify_import(path: &str, lang: Language) -> ImportStatement {
489 let kind = match lang {
490 Language::Rust => {
491 if path.ends_with("::*") {
492 if path.starts_with("crate::")
493 || path.starts_with("self::")
494 || path.starts_with("super::")
495 {
496 ImportKind::Wildcard
497 } else {
498 ImportKind::External
499 }
500 } else if path.starts_with("crate::")
501 || path.starts_with("self::")
502 || path.starts_with("super::")
503 {
504 ImportKind::Normal
505 } else {
506 ImportKind::External
507 }
508 }
509 Language::TypeScript | Language::JavaScript => {
510 if path.starts_with('.') {
511 ImportKind::Relative
512 } else {
513 ImportKind::External
514 }
515 }
516 Language::Python => {
517 if path.starts_with('.') {
518 ImportKind::Relative
519 } else {
520 ImportKind::Normal
521 }
522 }
523 _ => ImportKind::Normal,
524 };
525 ImportStatement::new(path, kind, 0)
526 }
527
528 fn analysis(path: &str, lang: Language, imports: &[&str]) -> StaticFileAnalysis {
529 StaticFileAnalysis {
530 path: path.to_string(),
531 language: lang,
532 entry_points: vec![],
533 exported_types: vec![],
534 imports: imports.iter().map(|s| classify_import(s, lang)).collect(),
535 todos: vec![],
536 unsafe_count: 0,
537 unwrap_count: 0,
538 panic_count: 0,
539 branch_count: 0,
540 module_doc: None,
541 content_hash: None,
542 line_count: 0,
543 }
544 }
545
546 #[test]
549 fn rust_crate_import_resolves_to_file() {
550 let files = vec![
551 walked("src/lib.rs", Language::Rust),
552 walked("src/utils.rs", Language::Rust),
553 ];
554 let analyses = vec![
555 analysis("src/lib.rs", Language::Rust, &["crate::utils"]),
556 analysis("src/utils.rs", Language::Rust, &[]),
557 ];
558
559 let result = build_edges(&files, &analyses, &[]);
560 assert_eq!(result.edges.len(), 1);
561 assert_eq!(result.edges[0].0, "file:src/lib.rs");
562 assert_eq!(result.edges[0].1, EdgeKind::Imports);
563 assert_eq!(result.edges[0].2, "file:src/utils.rs");
564 assert_eq!(result.unresolved_imports, 0);
565 }
566
567 #[test]
568 fn rust_crate_import_resolves_to_mod_rs() {
569 let files = vec![
570 walked("src/lib.rs", Language::Rust),
571 walked("src/store/mod.rs", Language::Rust),
572 ];
573 let analyses = vec![
574 analysis("src/lib.rs", Language::Rust, &["crate::store"]),
575 analysis("src/store/mod.rs", Language::Rust, &[]),
576 ];
577
578 let result = build_edges(&files, &analyses, &[]);
579 assert_eq!(result.edges.len(), 1);
580 assert_eq!(result.edges[0].2, "file:src/store/mod.rs");
581 }
582
583 #[test]
584 fn rust_self_import_resolves_relative_module() {
585 let files = vec![
586 walked("src/store/mod.rs", Language::Rust),
587 walked("src/store/helpers.rs", Language::Rust),
588 ];
589 let analyses = vec![
590 analysis("src/store/mod.rs", Language::Rust, &["self::helpers"]),
591 analysis("src/store/helpers.rs", Language::Rust, &[]),
592 ];
593
594 let result = build_edges(&files, &analyses, &[]);
595 assert_eq!(result.edges.len(), 1);
596 assert_eq!(result.edges[0].2, "file:src/store/helpers.rs");
597 assert_eq!(result.unresolved_imports, 0);
598 }
599
600 #[test]
601 fn rust_super_import_resolves_parent_module() {
602 let files = vec![
603 walked("src/store/db.rs", Language::Rust),
604 walked("src/store/helpers.rs", Language::Rust),
605 ];
606 let analyses = vec![
607 analysis("src/store/db.rs", Language::Rust, &["super::helpers"]),
608 analysis("src/store/helpers.rs", Language::Rust, &[]),
609 ];
610
611 let result = build_edges(&files, &analyses, &[]);
612 assert_eq!(result.edges.len(), 1);
613 assert_eq!(result.edges[0].2, "file:src/store/helpers.rs");
614 assert_eq!(result.unresolved_imports, 0);
615 }
616
617 #[test]
618 fn rust_super_import_unresolved_when_target_missing() {
619 let files = vec![walked("src/store/db.rs", Language::Rust)];
620 let analyses = vec![analysis(
621 "src/store/db.rs",
622 Language::Rust,
623 &["super::helpers"],
624 )];
625
626 let result = build_edges(&files, &analyses, &[]);
627 assert_eq!(result.edges.len(), 0);
628 assert_eq!(result.unresolved_imports, 1);
629 }
630
631 #[test]
632 fn rust_nested_crate_import() {
633 let files = vec![
634 walked("src/main.rs", Language::Rust),
635 walked("src/store/db.rs", Language::Rust),
636 ];
637 let analyses = vec![
638 analysis("src/main.rs", Language::Rust, &["crate::store::db"]),
639 analysis("src/store/db.rs", Language::Rust, &[]),
640 ];
641
642 let result = build_edges(&files, &analyses, &[]);
643 assert_eq!(result.edges.len(), 1);
644 assert_eq!(result.edges[0].2, "file:src/store/db.rs");
645 }
646
647 #[test]
648 fn rust_std_import_skipped() {
649 let files = vec![walked("src/lib.rs", Language::Rust)];
650 let analyses = vec![analysis(
651 "src/lib.rs",
652 Language::Rust,
653 &["std::collections::HashMap"],
654 )];
655
656 let result = build_edges(&files, &analyses, &[]);
657 assert_eq!(result.edges.len(), 0);
658 assert_eq!(result.unresolved_imports, 0);
660 }
661
662 #[test]
663 fn rust_external_crate_import_skipped() {
664 let files = vec![walked("src/lib.rs", Language::Rust)];
665 let analyses = vec![analysis(
666 "src/lib.rs",
667 Language::Rust,
668 &["anyhow::Result", "serde::Serialize"],
669 )];
670
671 let result = build_edges(&files, &analyses, &[]);
672 assert_eq!(result.edges.len(), 0);
673 assert_eq!(result.unresolved_imports, 0);
675 }
676
677 #[test]
678 fn rust_no_self_edges() {
679 let files = vec![walked("src/store.rs", Language::Rust)];
680 let analyses = vec![analysis("src/store.rs", Language::Rust, &["crate::store"])];
681
682 let result = build_edges(&files, &analyses, &[]);
683 assert_eq!(result.edges.len(), 0);
684 }
685
686 #[test]
689 fn python_absolute_import_resolves() {
690 let files = vec![
691 walked("app/main.py", Language::Python),
692 walked("app/utils.py", Language::Python),
693 ];
694 let analyses = vec![
695 analysis("app/main.py", Language::Python, &["app.utils"]),
696 analysis("app/utils.py", Language::Python, &[]),
697 ];
698
699 let result = build_edges(&files, &analyses, &[]);
700 assert_eq!(result.edges.len(), 1);
701 assert_eq!(result.edges[0].2, "file:app/utils.py");
702 }
703
704 #[test]
705 fn python_relative_import_resolves() {
706 let files = vec![
707 walked("app/main.py", Language::Python),
708 walked("app/helpers.py", Language::Python),
709 ];
710 let analyses = vec![
711 analysis("app/main.py", Language::Python, &[".helpers"]),
712 analysis("app/helpers.py", Language::Python, &[]),
713 ];
714
715 let result = build_edges(&files, &analyses, &[]);
716 assert_eq!(result.edges.len(), 1);
717 assert_eq!(result.edges[0].2, "file:app/helpers.py");
718 }
719
720 #[test]
721 fn python_package_init_resolves() {
722 let files = vec![
723 walked("main.py", Language::Python),
724 walked("pkg/__init__.py", Language::Python),
725 ];
726 let analyses = vec![
727 analysis("main.py", Language::Python, &["pkg"]),
728 analysis("pkg/__init__.py", Language::Python, &[]),
729 ];
730
731 let result = build_edges(&files, &analyses, &[]);
732 assert_eq!(result.edges.len(), 1);
733 assert_eq!(result.edges[0].2, "file:pkg/__init__.py");
734 }
735
736 #[test]
737 fn rust_unknown_import_returns_none() {
738 let files = vec![walked("src/lib.rs", Language::Rust)];
739 let analyses = vec![analysis(
740 "src/lib.rs",
741 Language::Rust,
742 &["crate::nonexistent"],
743 )];
744
745 let result = build_edges(&files, &analyses, &[]);
746 assert_eq!(result.edges.len(), 0);
747 assert_eq!(result.unresolved_imports, 1);
748 }
749
750 #[test]
751 fn python_unknown_import_returns_none() {
752 let files = vec![walked("app/main.py", Language::Python)];
753 let analyses = vec![analysis(
754 "app/main.py",
755 Language::Python,
756 &["app.nonexistent"],
757 )];
758
759 let result = build_edges(&files, &analyses, &[]);
760 assert_eq!(result.edges.len(), 0);
761 assert_eq!(result.unresolved_imports, 1);
762 }
763
764 #[test]
767 fn ts_relative_import_resolves() {
768 let files = vec![
769 walked("src/app.ts", Language::TypeScript),
770 walked("src/utils.ts", Language::TypeScript),
771 ];
772 let analyses = vec![
773 analysis("src/app.ts", Language::TypeScript, &["./utils"]),
774 analysis("src/utils.ts", Language::TypeScript, &[]),
775 ];
776
777 let result = build_edges(&files, &analyses, &[]);
778 assert_eq!(result.edges.len(), 1);
779 assert_eq!(result.edges[0].2, "file:src/utils.ts");
780 }
781
782 #[test]
783 fn ts_relative_import_parent_dir() {
784 let files = vec![
785 walked("src/components/button.tsx", Language::TypeScript),
786 walked("src/utils.ts", Language::TypeScript),
787 ];
788 let analyses = vec![
789 analysis(
790 "src/components/button.tsx",
791 Language::TypeScript,
792 &["../utils"],
793 ),
794 analysis("src/utils.ts", Language::TypeScript, &[]),
795 ];
796
797 let result = build_edges(&files, &analyses, &[]);
798 assert_eq!(result.edges.len(), 1);
799 assert_eq!(result.edges[0].2, "file:src/utils.ts");
800 }
801
802 #[test]
803 fn ts_index_file_resolves() {
804 let files = vec![
805 walked("src/app.ts", Language::TypeScript),
806 walked("src/components/index.ts", Language::TypeScript),
807 ];
808 let analyses = vec![
809 analysis("src/app.ts", Language::TypeScript, &["./components"]),
810 analysis("src/components/index.ts", Language::TypeScript, &[]),
811 ];
812
813 let result = build_edges(&files, &analyses, &[]);
814 assert_eq!(result.edges.len(), 1);
815 assert_eq!(result.edges[0].2, "file:src/components/index.ts");
816 }
817
818 #[test]
819 fn ts_bare_specifier_skipped() {
820 let files = vec![walked("src/app.ts", Language::TypeScript)];
821 let analyses = vec![analysis(
822 "src/app.ts",
823 Language::TypeScript,
824 &["react", "@tanstack/query"],
825 )];
826
827 let result = build_edges(&files, &analyses, &[]);
828 assert_eq!(result.edges.len(), 0);
829 assert_eq!(result.unresolved_imports, 0);
831 }
832
833 #[test]
834 fn js_relative_import_resolves_to_js() {
835 let files = vec![
836 walked("lib/index.js", Language::JavaScript),
837 walked("lib/helpers.js", Language::JavaScript),
838 ];
839 let analyses = vec![
840 analysis("lib/index.js", Language::JavaScript, &["./helpers"]),
841 analysis("lib/helpers.js", Language::JavaScript, &[]),
842 ];
843
844 let result = build_edges(&files, &analyses, &[]);
845 assert_eq!(result.edges.len(), 1);
846 assert_eq!(result.edges[0].2, "file:lib/helpers.js");
847 }
848
849 #[test]
852 fn co_change_creates_bidirectional_edges() {
853 let files = vec![
854 walked("src/a.rs", Language::Rust),
855 walked("src/b.rs", Language::Rust),
856 ];
857 let analyses = vec![
858 analysis("src/a.rs", Language::Rust, &[]),
859 analysis("src/b.rs", Language::Rust, &[]),
860 ];
861 let pairs = vec![("src/a.rs".to_string(), "src/b.rs".to_string(), 5)];
862
863 let result = build_edges(&files, &analyses, &pairs);
864 assert_eq!(result.edges.len(), 2);
865
866 let has_a_to_b = result.edges.iter().any(|(from, kind, to)| {
867 from == "file:src/a.rs" && *kind == EdgeKind::CoChanges && to == "file:src/b.rs"
868 });
869 let has_b_to_a = result.edges.iter().any(|(from, kind, to)| {
870 from == "file:src/b.rs" && *kind == EdgeKind::CoChanges && to == "file:src/a.rs"
871 });
872 assert!(has_a_to_b, "missing a→b edge");
873 assert!(has_b_to_a, "missing b→a edge");
874 }
875
876 #[test]
877 fn co_change_skips_unknown_files() {
878 let files = vec![walked("src/a.rs", Language::Rust)];
879 let analyses = vec![analysis("src/a.rs", Language::Rust, &[])];
880 let pairs = vec![("src/a.rs".to_string(), "src/b.rs".to_string(), 3)];
882
883 let result = build_edges(&files, &analyses, &pairs);
884 assert_eq!(result.edges.len(), 0);
885 }
886
887 #[test]
890 fn imports_and_co_changes_combined() {
891 let files = vec![
892 walked("src/lib.rs", Language::Rust),
893 walked("src/store.rs", Language::Rust),
894 walked("src/search.rs", Language::Rust),
895 ];
896 let analyses = vec![
897 analysis(
898 "src/lib.rs",
899 Language::Rust,
900 &["crate::store", "crate::search"],
901 ),
902 analysis("src/store.rs", Language::Rust, &[]),
903 analysis("src/search.rs", Language::Rust, &[]),
904 ];
905 let pairs = vec![("src/search.rs".to_string(), "src/store.rs".to_string(), 4)];
906
907 let result = build_edges(&files, &analyses, &pairs);
908
909 let import_count = result
910 .edges
911 .iter()
912 .filter(|(_, k, _)| *k == EdgeKind::Imports)
913 .count();
914 let co_change_count = result
915 .edges
916 .iter()
917 .filter(|(_, k, _)| *k == EdgeKind::CoChanges)
918 .count();
919
920 assert_eq!(import_count, 2); assert_eq!(co_change_count, 2); }
923
924 #[test]
925 fn empty_inputs_produce_no_edges() {
926 let result = build_edges(&[], &[], &[]);
927 assert_eq!(result.edges.len(), 0);
928 assert_eq!(result.unresolved_imports, 0);
929 }
930
931 fn analysis_with_imports(
935 path: &str,
936 lang: Language,
937 imports: Vec<ImportStatement>,
938 ) -> StaticFileAnalysis {
939 StaticFileAnalysis {
940 path: path.to_string(),
941 language: lang,
942 entry_points: vec![],
943 exported_types: vec![],
944 imports,
945 todos: vec![],
946 unsafe_count: 0,
947 unwrap_count: 0,
948 panic_count: 0,
949 branch_count: 0,
950 module_doc: None,
951 content_hash: None,
952 line_count: 0,
953 }
954 }
955
956 #[test]
957 fn cpp_angle_bracket_internal_include_resolves() {
958 let files = vec![
961 walked("tests/test.cpp", Language::Cpp),
962 walked("include/nlohmann/json.hpp", Language::Cpp),
963 ];
964 let analyses = vec![
965 analysis_with_imports(
966 "tests/test.cpp",
967 Language::Cpp,
968 vec![ImportStatement::new(
969 "nlohmann/json.hpp",
970 ImportKind::External,
971 1,
972 )],
973 ),
974 analysis_with_imports("include/nlohmann/json.hpp", Language::Cpp, vec![]),
975 ];
976
977 let result = build_edges(&files, &analyses, &[]);
978 assert_eq!(
979 result.edges.len(),
980 1,
981 "angle-bracket include that resolves to a repo file should produce an edge"
982 );
983 assert_eq!(result.edges[0].2, "file:include/nlohmann/json.hpp");
984 }
985
986 #[test]
987 fn cpp_angle_bracket_external_stays_skipped() {
988 let files = vec![walked("src/main.cpp", Language::Cpp)];
990 let analyses = vec![analysis_with_imports(
991 "src/main.cpp",
992 Language::Cpp,
993 vec![ImportStatement::new("vector", ImportKind::External, 1)],
994 )];
995
996 let result = build_edges(&files, &analyses, &[]);
997 assert_eq!(result.edges.len(), 0);
998 assert_eq!(result.unresolved_imports, 0);
1000 }
1001
1002 #[test]
1003 fn cpp_quoted_include_unchanged() {
1004 let files = vec![
1006 walked("src/main.cpp", Language::Cpp),
1007 walked("src/helper.h", Language::Cpp),
1008 ];
1009 let analyses = vec![
1010 analysis_with_imports(
1011 "src/main.cpp",
1012 Language::Cpp,
1013 vec![ImportStatement::new("helper.h", ImportKind::Relative, 1)],
1014 ),
1015 analysis_with_imports("src/helper.h", Language::Cpp, vec![]),
1016 ];
1017
1018 let result = build_edges(&files, &analyses, &[]);
1019 assert_eq!(result.edges.len(), 1);
1020 assert_eq!(result.edges[0].2, "file:src/helper.h");
1021 }
1022}