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    // An edge's identity is its whole `(from, kind, to)` triple, which is also
183    // its storage key, so duplicates collapse on write anyway. Several `use`
184    // lines in one file routinely resolve to the same target; deduping here
185    // keeps the reported count equal to what the store actually holds.
186    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
195/// Detect Rust crate root prefixes from `Cargo.toml`.
196///
197/// For workspace projects, reads `[workspace].members` and expands globs to
198/// produce roots like `"crates/regex/src/"`. For single-crate projects,
199/// produces `["src/"]` if `src/` contains Rust files.
200pub 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    // Check for [workspace] section with members.
215    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 workspace members produced roots, also check if the root crate has src/.
226    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    // Single-crate project: if [package] exists and src/ has Rust files.
234    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
243/// Expand a workspace member pattern (e.g. `"crates/*"`) into `<member>/src/` roots.
244fn expand_workspace_member(repo_root: &Path, pattern: &str, roots: &mut Vec<String>) {
245    if pattern.contains('*') {
246        // Glob: e.g. "crates/*" — enumerate matching directories.
247        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        // Literal member: e.g. "crates/foo"
261        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
269/// Detect workspace member crate names from each member's `Cargo.toml`.
270///
271/// Returns a map from snake_case crate name (as used in `use` statements)
272/// to the crate root path (e.g. `"crates/regex/src/"`).
273pub 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    // Collect all member directories (expanding globs).
296    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    // Also check if the root crate is part of the workspace.
304    if doc.get("package").is_some() {
305        member_dirs.push(repo_root.to_path_buf());
306    }
307
308    // Read each member's Cargo.toml to extract [package].name.
309    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        // Compute the crate root path relative to repo root.
329        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        // Normalize kebab-case to snake_case (grep-regex → grep_regex).
338        let snake_name = name.replace('-', "_");
339        members.insert(snake_name, crate_root);
340    }
341
342    members
343}
344
345/// Collect member directories from a workspace member pattern, expanding globs.
346fn 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
365/// Discover Scala source root prefixes from walked file paths.
366///
367/// Scans for directories matching sbt/Maven conventions:
368/// `**/src/main/scala/`, `**/src/test/scala/`, and Scala-version-specific
369/// variants. Returns each discovered root as a path prefix suitable for
370/// prepending to a dotted-import-derived relative path.
371fn 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(); // Deterministic order for tests.
398    result
399}
400
401/// Discover Ruby/Rails autoload roots and lib/ roots from walked file paths.
402///
403/// Scans Ruby files for paths matching Rails conventions:
404/// - `**/app/<subdir>/` directories → Zeitwerk autoload roots
405/// - `**/lib/` directories → lib roots for `require` resolution
406///
407/// Returns `(autoload_roots, lib_roots)`. Both sorted for deterministic output.
408fn 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        // Detect app/<subdir>/ autoload roots.
419        // E.g. "core/app/models/spree/order.rb" → "core/app/models/"
420        // E.g. "app/models/concerns/searchable.rb" → "app/models/concerns/"
421        //
422        // Strategy: find the "app/" segment, extract the prefix before it and
423        // the first subdir after it. Register both `<prefix>app/<subdir>/` and,
424        // if the file also descends into `concerns/`, `<prefix>app/<subdir>/concerns/`.
425        if let Some(app_idx) = find_segment(path, "app/") {
426            let after_app = &path[app_idx + 4..]; // skip "app/"
427            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                // Check for concerns/ before inserting to avoid extra allocation.
433                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        // Detect lib/ roots (monorepo-aware).
443        // E.g. "core/lib/spree/core.rb" → "core/lib/"
444        // E.g. "lib/discourse.rb" → "lib/"
445        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
458/// Find a directory segment in a path, returning its byte offset.
459///
460/// Matches at position 0 (path starts with segment) or after a `/`.
461/// E.g. `find_segment("core/app/models/foo.rb", "app/")` → `Some(5)`.
462fn 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
470/// Format a repo-relative path as a record key.
471fn file_key(rel_path: &str) -> String {
472    format!("file:{rel_path}")
473}
474
475// ── Tests ─────────────────────────────────────────────────────────────────────
476
477#[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    /// Classify an import string the same way the parsers do, for test ergonomics.
495    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    // ── Rust import resolution ──────────────────────────────────────────────
554
555    #[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        // std:: is external — filtered before resolution, not counted as unresolved.
685        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        // External crates filtered before resolution.
700        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    // ── Python import resolution ────────────────────────────────────────────
713
714    #[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    // ── TypeScript/JavaScript import resolution ─────────────────────────────
791
792    #[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        // Bare specifiers are not counted as unresolved — they're intentionally skipped.
856        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    // ── Co-change edges ─────────────────────────────────────────────────────
876
877    #[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        // b.rs is in the co-change pair but not in walked files
907        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    // ── Mixed ───────────────────────────────────────────────────────────────
914
915    #[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); // lib→store, lib→search
947        assert_eq!(co_change_count, 2); // search↔store (bidirectional)
948    }
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    // ── C/C++ angle-bracket resolution ─────────────────────────────────
958
959    /// Helper: build a StaticFileAnalysis with explicit ImportStatements.
960    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        // #include <nlohmann/json.hpp> from a test file — the header exists
985        // under include/, so the angle-bracket exception should create an edge.
986        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        // #include <vector> — no matching file, should produce no edge.
1015        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        // External imports that fail resolution are not counted as unresolved.
1025        assert_eq!(result.unresolved_imports, 0);
1026    }
1027
1028    #[test]
1029    fn cpp_quoted_include_unchanged() {
1030        // #include "helper.h" — Relative kind, resolved through the normal path.
1031        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}