Skip to main content

mati_core/analysis/
edges.rs

1//! Graph edge construction from Layer 0 signals (M-06-G).
2//!
3//! Converts import statements and git co-change pairs into typed edges
4//! ready for [`crate::graph::Graph::add_edges_batch`].
5//!
6//! Import resolution is best-effort: structured `ImportStatement` values
7//! are resolved via the [`ResolverRegistry`] against the set of known
8//! walked files. External imports (classified at parse time) are skipped.
9//! Unresolvable imports are silently counted — Layer 0 favours precision
10//! over recall.
11
12use 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
21/// All edges produced by Layer 0 analysis, ready for batch insertion.
22pub struct Layer0Edges {
23    pub edges: Vec<(String, EdgeKind, String)>,
24    /// Import paths that could not be resolved to a known file.
25    pub unresolved_imports: usize,
26}
27
28/// Build graph edges from static analysis and git signals.
29///
30/// Returns `Imports` edges from resolved import statements and `CoChanges`
31/// edges from git co-change pairs. Both use `file:<rel_path>` keys matching
32/// the record key format.
33pub 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
41/// Build edges with an explicit repo root for resolvers that need file content
42/// access (e.g. Go's go.mod parsing).
43pub 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    // Derive the repo root from the first file if not provided explicitly.
58    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    // Detect Rust crate roots and workspace members from Cargo.toml.
75    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    // Detect Scala source roots from file paths (multi-project sbt layouts).
87    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    // Detect Ruby/Rails autoload roots and monorepo lib/ roots.
93    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    // ── Import edges ────────────────────────────────────────────────────────
107    for (file, analysis) in files.iter().zip(analyses.iter()) {
108        // Skip files with no imports — avoid allocating from_key.
109        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            // Skip imports classified as external at parse time —
117            // but for Rust files in a workspace, try cross-crate resolution first.
118            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                // C/C++ angle-bracket includes classified as External may
131                // actually be project-internal (e.g. `#include <nlohmann/json.hpp>`).
132                // Try to resolve them against the file index before skipping.
133                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                // No self-edges.
161                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    // ── Co-change edges ─────────────────────────────────────────────────────
171    // Pairs are (a, b, count) with a < b. Create edges in both directions
172    // so graph traversal works regardless of starting node.
173    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
188/// Detect Rust crate root prefixes from `Cargo.toml`.
189///
190/// For workspace projects, reads `[workspace].members` and expands globs to
191/// produce roots like `"crates/regex/src/"`. For single-crate projects,
192/// produces `["src/"]` if `src/` contains Rust files.
193pub 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    // Check for [workspace] section with members.
208    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 workspace members produced roots, also check if the root crate has src/.
219    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    // Single-crate project: if [package] exists and src/ has Rust files.
227    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
236/// Expand a workspace member pattern (e.g. `"crates/*"`) into `<member>/src/` roots.
237fn expand_workspace_member(repo_root: &Path, pattern: &str, roots: &mut Vec<String>) {
238    if pattern.contains('*') {
239        // Glob: e.g. "crates/*" — enumerate matching directories.
240        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        // Literal member: e.g. "crates/foo"
254        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
262/// Detect workspace member crate names from each member's `Cargo.toml`.
263///
264/// Returns a map from snake_case crate name (as used in `use` statements)
265/// to the crate root path (e.g. `"crates/regex/src/"`).
266pub 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    // Collect all member directories (expanding globs).
289    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    // Also check if the root crate is part of the workspace.
297    if doc.get("package").is_some() {
298        member_dirs.push(repo_root.to_path_buf());
299    }
300
301    // Read each member's Cargo.toml to extract [package].name.
302    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        // Compute the crate root path relative to repo root.
322        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        // Normalize kebab-case to snake_case (grep-regex → grep_regex).
331        let snake_name = name.replace('-', "_");
332        members.insert(snake_name, crate_root);
333    }
334
335    members
336}
337
338/// Collect member directories from a workspace member pattern, expanding globs.
339fn 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
358/// Discover Scala source root prefixes from walked file paths.
359///
360/// Scans for directories matching sbt/Maven conventions:
361/// `**/src/main/scala/`, `**/src/test/scala/`, and Scala-version-specific
362/// variants. Returns each discovered root as a path prefix suitable for
363/// prepending to a dotted-import-derived relative path.
364fn 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(); // Deterministic order for tests.
391    result
392}
393
394/// Discover Ruby/Rails autoload roots and lib/ roots from walked file paths.
395///
396/// Scans Ruby files for paths matching Rails conventions:
397/// - `**/app/<subdir>/` directories → Zeitwerk autoload roots
398/// - `**/lib/` directories → lib roots for `require` resolution
399///
400/// Returns `(autoload_roots, lib_roots)`. Both sorted for deterministic output.
401fn 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        // Detect app/<subdir>/ autoload roots.
412        // E.g. "core/app/models/spree/order.rb" → "core/app/models/"
413        // E.g. "app/models/concerns/searchable.rb" → "app/models/concerns/"
414        //
415        // Strategy: find the "app/" segment, extract the prefix before it and
416        // the first subdir after it. Register both `<prefix>app/<subdir>/` and,
417        // if the file also descends into `concerns/`, `<prefix>app/<subdir>/concerns/`.
418        if let Some(app_idx) = find_segment(path, "app/") {
419            let after_app = &path[app_idx + 4..]; // skip "app/"
420            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                // Check for concerns/ before inserting to avoid extra allocation.
426                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        // Detect lib/ roots (monorepo-aware).
436        // E.g. "core/lib/spree/core.rb" → "core/lib/"
437        // E.g. "lib/discourse.rb" → "lib/"
438        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
451/// Find a directory segment in a path, returning its byte offset.
452///
453/// Matches at position 0 (path starts with segment) or after a `/`.
454/// E.g. `find_segment("core/app/models/foo.rb", "app/")` → `Some(5)`.
455fn 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
463/// Format a repo-relative path as a record key.
464fn file_key(rel_path: &str) -> String {
465    format!("file:{rel_path}")
466}
467
468// ── Tests ─────────────────────────────────────────────────────────────────────
469
470#[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    /// Classify an import string the same way the parsers do, for test ergonomics.
488    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    // ── Rust import resolution ──────────────────────────────────────────────
547
548    #[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        // std:: is external — filtered before resolution, not counted as unresolved.
659        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        // External crates filtered before resolution.
674        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    // ── Python import resolution ────────────────────────────────────────────
687
688    #[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    // ── TypeScript/JavaScript import resolution ─────────────────────────────
765
766    #[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        // Bare specifiers are not counted as unresolved — they're intentionally skipped.
830        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    // ── Co-change edges ─────────────────────────────────────────────────────
850
851    #[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        // b.rs is in the co-change pair but not in walked files
881        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    // ── Mixed ───────────────────────────────────────────────────────────────
888
889    #[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); // lib→store, lib→search
921        assert_eq!(co_change_count, 2); // search↔store (bidirectional)
922    }
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    // ── C/C++ angle-bracket resolution ─────────────────────────────────
932
933    /// Helper: build a StaticFileAnalysis with explicit ImportStatements.
934    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        // #include <nlohmann/json.hpp> from a test file — the header exists
959        // under include/, so the angle-bracket exception should create an edge.
960        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        // #include <vector> — no matching file, should produce no edge.
989        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        // External imports that fail resolution are not counted as unresolved.
999        assert_eq!(result.unresolved_imports, 0);
1000    }
1001
1002    #[test]
1003    fn cpp_quoted_include_unchanged() {
1004        // #include "helper.h" — Relative kind, resolved through the normal path.
1005        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}