Skip to main content

sqz_engine/
dependency_mapper.rs

1//! Dependency graph builder that parses import/require/use statements
2//! to map relationships between files in a project.
3//!
4//! Supports the same 18 languages as `AstParser` via regex-based import
5//! extraction. The graph is cached and incrementally updated on file changes.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10/// A directed dependency graph mapping files to their imports and reverse dependencies.
11#[derive(Debug, Clone)]
12pub struct DependencyMapper {
13    /// path → set of paths that `path` imports
14    dependencies: HashMap<PathBuf, HashSet<PathBuf>>,
15    /// path → set of paths that import `path`
16    dependents: HashMap<PathBuf, HashSet<PathBuf>>,
17}
18
19impl DependencyMapper {
20    /// Create an empty dependency mapper.
21    pub fn new() -> Self {
22        Self {
23            dependencies: HashMap::new(),
24            dependents: HashMap::new(),
25        }
26    }
27
28    /// Parse import statements from `source` for the file at `path` and add
29    /// the discovered edges to the graph. If the file was already tracked,
30    /// its old edges are removed first (incremental update).
31    pub fn add_file(&mut self, path: &Path, source: &str) {
32        // Remove stale edges if the file was previously tracked
33        self.remove_file(path);
34
35        let lang = detect_language(path);
36        let raw_imports = parse_imports(source, lang.as_deref());
37
38        let dir = path.parent().unwrap_or(Path::new(""));
39        let resolved: HashSet<PathBuf> = raw_imports
40            .into_iter()
41            .filter_map(|imp| resolve_import(&imp, dir))
42            .collect();
43
44        // Insert forward edges
45        self.dependencies.insert(path.to_path_buf(), resolved.clone());
46
47        // Insert reverse edges
48        for dep in &resolved {
49            self.dependents
50                .entry(dep.clone())
51                .or_default()
52                .insert(path.to_path_buf());
53        }
54    }
55
56    /// Remove a file and all its edges from the graph.
57    pub fn remove_file(&mut self, path: &Path) {
58        if let Some(old_deps) = self.dependencies.remove(path) {
59            for dep in &old_deps {
60                if let Some(rev) = self.dependents.get_mut(dep) {
61                    rev.remove(path);
62                    if rev.is_empty() {
63                        self.dependents.remove(dep);
64                    }
65                }
66            }
67        }
68        // Also clean up any reverse entries pointing to this file
69        if let Some(old_rev) = self.dependents.remove(path) {
70            for src in &old_rev {
71                if let Some(fwd) = self.dependencies.get_mut(src) {
72                    fwd.remove(path);
73                }
74            }
75        }
76    }
77
78    /// Returns the set of files that `path` imports (direct dependencies).
79    pub fn dependencies_of(&self, path: &Path) -> Vec<PathBuf> {
80        let mut deps: Vec<PathBuf> = self
81            .dependencies
82            .get(path)
83            .map(|s| s.iter().cloned().collect())
84            .unwrap_or_default();
85        deps.sort();
86        deps
87    }
88
89    /// Returns the set of files that import `path` (reverse dependencies).
90    pub fn dependents_of(&self, path: &Path) -> Vec<PathBuf> {
91        let mut deps: Vec<PathBuf> = self
92            .dependents
93            .get(path)
94            .map(|s| s.iter().cloned().collect())
95            .unwrap_or_default();
96        deps.sort();
97        deps
98    }
99
100    /// Produce a compact dependency summary string suitable for inclusion
101    /// in file read output.
102    pub fn summary(&self, path: &Path) -> String {
103        let deps = self.dependencies_of(path);
104        let revs = self.dependents_of(path);
105
106        if deps.is_empty() && revs.is_empty() {
107            return String::new();
108        }
109
110        let mut parts = Vec::new();
111
112        if !deps.is_empty() {
113            let names: Vec<String> = deps
114                .iter()
115                .map(|p| short_name(p))
116                .collect();
117            parts.push(format!("imports: {}", names.join(", ")));
118        }
119
120        if !revs.is_empty() {
121            let names: Vec<String> = revs
122                .iter()
123                .map(|p| short_name(p))
124                .collect();
125            parts.push(format!("imported by: {}", names.join(", ")));
126        }
127
128        format!("[deps] {}", parts.join(" | "))
129    }
130
131    /// Returns the number of tracked files.
132    pub fn file_count(&self) -> usize {
133        self.dependencies.len()
134    }
135}
136
137impl Default for DependencyMapper {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143/// Extract the file name (or last two path components) for compact display.
144fn short_name(path: &Path) -> String {
145    let components: Vec<&str> = path
146        .components()
147        .filter_map(|c| c.as_os_str().to_str())
148        .collect();
149    if components.len() <= 2 {
150        path.to_string_lossy().to_string()
151    } else {
152        components[components.len() - 2..].join("/")
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Language detection (mirrors ast_parser logic)
158// ---------------------------------------------------------------------------
159
160fn detect_language(path: &Path) -> Option<String> {
161    let ext = path.extension()?.to_str()?;
162    let lang = match ext {
163        "rs" => "rust",
164        "py" => "python",
165        "js" | "mjs" | "cjs" => "javascript",
166        "ts" | "tsx" | "mts" => "typescript",
167        "go" => "go",
168        "java" => "java",
169        "c" | "h" => "c",
170        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
171        "rb" => "ruby",
172        "sh" | "bash" => "bash",
173        "json" => "json",
174        "html" | "htm" => "html",
175        "css" | "scss" | "less" => "css",
176        "cs" => "csharp",
177        "kt" | "kts" => "kotlin",
178        "swift" => "swift",
179        "toml" => "toml",
180        "yaml" | "yml" => "yaml",
181        _ => return None,
182    };
183    Some(lang.to_string())
184}
185
186// ---------------------------------------------------------------------------
187// Import parsing — regex-based patterns per language
188// ---------------------------------------------------------------------------
189
190/// Parse raw import specifiers from source code. Returns the module/path
191/// strings as written in the source (not yet resolved to file paths).
192fn parse_imports(source: &str, language: Option<&str>) -> Vec<String> {
193    let lang = match language {
194        Some(l) => l,
195        None => return Vec::new(),
196    };
197
198    let mut imports = Vec::new();
199
200    for line in source.lines() {
201        let trimmed = line.trim();
202        if trimmed.is_empty() {
203            continue;
204        }
205
206        match lang {
207            "rust" => parse_rust_import(trimmed, &mut imports),
208            "python" => parse_python_import(trimmed, &mut imports),
209            "javascript" | "typescript" => parse_js_ts_import(trimmed, &mut imports),
210            "go" => parse_go_import(trimmed, &mut imports),
211            "java" | "kotlin" => parse_java_kotlin_import(trimmed, &mut imports),
212            "c" | "cpp" => parse_c_cpp_import(trimmed, &mut imports),
213            "ruby" => parse_ruby_import(trimmed, &mut imports),
214            "csharp" => parse_csharp_import(trimmed, &mut imports),
215            "swift" => parse_swift_import(trimmed, &mut imports),
216            "css" => parse_css_import(trimmed, &mut imports),
217            "html" => parse_html_import(trimmed, &mut imports),
218            _ => {} // json, toml, yaml, bash — no meaningful imports
219        }
220    }
221
222    imports
223}
224
225/// Rust: `use crate::foo::bar;` or `use super::baz;` or `mod foo;`
226fn parse_rust_import(line: &str, imports: &mut Vec<String>) {
227    if let Some(rest) = line.strip_prefix("use ") {
228        let spec = rest.trim_end_matches(';').trim();
229        // Extract the path portion (before any `{` or `as`)
230        let path = spec.split('{').next().unwrap_or(spec);
231        let path = path.split(" as ").next().unwrap_or(path).trim();
232        if !path.is_empty() {
233            imports.push(path.to_string());
234        }
235    } else if let Some(rest) = line.strip_prefix("mod ") {
236        let name = rest.trim_end_matches(';').trim();
237        if !name.is_empty() && !line.contains('{') {
238            imports.push(name.to_string());
239        }
240    }
241}
242
243/// Python: `import foo` or `from foo import bar` or `from . import baz`
244fn parse_python_import(line: &str, imports: &mut Vec<String>) {
245    if let Some(rest) = line.strip_prefix("from ") {
246        // `from foo.bar import baz`
247        if let Some(module) = rest.split(" import").next() {
248            let module = module.trim();
249            if !module.is_empty() {
250                imports.push(module.to_string());
251            }
252        }
253    } else if let Some(rest) = line.strip_prefix("import ") {
254        // `import foo, bar` or `import foo.bar`
255        for part in rest.split(',') {
256            let module = part.split(" as ").next().unwrap_or(part).trim();
257            if !module.is_empty() {
258                imports.push(module.to_string());
259            }
260        }
261    }
262}
263
264/// JS/TS: `import ... from '...'`, `require('...')`, `import '...'`
265fn parse_js_ts_import(line: &str, imports: &mut Vec<String>) {
266    // Static import: import X from 'path' or import 'path'
267    if line.starts_with("import ") || line.starts_with("export ") {
268        if let Some(path) = extract_quoted_string(line, "from ") {
269            imports.push(path);
270        } else if line.starts_with("import ") {
271            // `import './side-effect'`
272            if let Some(path) = extract_first_quoted(line) {
273                imports.push(path);
274            }
275        }
276    }
277    // CommonJS require
278    if line.contains("require(") {
279        if let Some(path) = extract_require_path(line) {
280            imports.push(path);
281        }
282    }
283}
284
285/// Go: `import "path"` or `import ( "path" )`
286fn parse_go_import(line: &str, imports: &mut Vec<String>) {
287    let trimmed = line.trim();
288    if trimmed.starts_with("import ") || trimmed.starts_with("\"") {
289        if let Some(path) = extract_first_quoted(trimmed) {
290            imports.push(path);
291        }
292    }
293}
294
295/// Java/Kotlin: `import foo.bar.Baz;`
296fn parse_java_kotlin_import(line: &str, imports: &mut Vec<String>) {
297    if let Some(rest) = line.strip_prefix("import ") {
298        let rest = rest.strip_prefix("static ").unwrap_or(rest);
299        let path = rest.trim_end_matches(';').trim();
300        if !path.is_empty() {
301            imports.push(path.to_string());
302        }
303    }
304}
305
306/// C/C++: `#include <header>` or `#include "header"`
307fn parse_c_cpp_import(line: &str, imports: &mut Vec<String>) {
308    if let Some(rest) = line.strip_prefix("#include") {
309        let rest = rest.trim();
310        if let Some(path) = rest.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
311            imports.push(path.to_string());
312        } else if let Some(path) = rest.strip_prefix('<').and_then(|r| r.strip_suffix('>')) {
313            imports.push(path.to_string());
314        }
315    }
316}
317
318/// Ruby: `require 'foo'` or `require_relative 'foo'`
319fn parse_ruby_import(line: &str, imports: &mut Vec<String>) {
320    if line.starts_with("require ") || line.starts_with("require_relative ") {
321        if let Some(path) = extract_first_quoted(line) {
322            imports.push(path);
323        }
324    }
325}
326
327/// C#: `using Foo.Bar;`
328fn parse_csharp_import(line: &str, imports: &mut Vec<String>) {
329    if let Some(rest) = line.strip_prefix("using ") {
330        // Skip `using static` or `using var` (not imports)
331        if rest.starts_with("var ") || rest.contains('=') {
332            return;
333        }
334        let rest = rest.strip_prefix("static ").unwrap_or(rest);
335        let ns = rest.trim_end_matches(';').trim();
336        if !ns.is_empty() {
337            imports.push(ns.to_string());
338        }
339    }
340}
341
342/// Swift: `import Foundation`
343fn parse_swift_import(line: &str, imports: &mut Vec<String>) {
344    if let Some(rest) = line.strip_prefix("import ") {
345        let module = rest.trim();
346        if !module.is_empty() {
347            imports.push(module.to_string());
348        }
349    }
350}
351
352/// CSS: `@import url('...')` or `@import '...'`
353fn parse_css_import(line: &str, imports: &mut Vec<String>) {
354    if line.starts_with("@import") {
355        if let Some(path) = extract_first_quoted(line) {
356            imports.push(path);
357        }
358    }
359}
360
361/// HTML: `<script src="...">` or `<link href="...">`
362fn parse_html_import(line: &str, imports: &mut Vec<String>) {
363    for attr in &["src=\"", "href=\""] {
364        if let Some(idx) = line.find(attr) {
365            let start = idx + attr.len();
366            if let Some(end) = line[start..].find('"') {
367                let path = &line[start..start + end];
368                if !path.is_empty() {
369                    imports.push(path.to_string());
370                }
371            }
372        }
373    }
374}
375
376// ---------------------------------------------------------------------------
377// String extraction helpers
378// ---------------------------------------------------------------------------
379
380/// Extract the quoted string after a keyword, e.g. `from 'foo'` → `foo`.
381fn extract_quoted_string(line: &str, keyword: &str) -> Option<String> {
382    let idx = line.find(keyword)?;
383    let rest = &line[idx + keyword.len()..];
384    extract_first_quoted(rest)
385}
386
387/// Extract the first single- or double-quoted string from text.
388fn extract_first_quoted(text: &str) -> Option<String> {
389    for quote in &['\'', '"'] {
390        if let Some(start) = text.find(*quote) {
391            let rest = &text[start + 1..];
392            if let Some(end) = rest.find(*quote) {
393                let val = &rest[..end];
394                if !val.is_empty() {
395                    return Some(val.to_string());
396                }
397            }
398        }
399    }
400    None
401}
402
403/// Extract path from `require('...')` or `require("...")`.
404fn extract_require_path(line: &str) -> Option<String> {
405    let idx = line.find("require(")?;
406    let rest = &line[idx + "require(".len()..];
407    // Find closing paren
408    let paren_end = rest.find(')')?;
409    let inner = &rest[..paren_end];
410    extract_first_quoted(inner)
411}
412
413// ---------------------------------------------------------------------------
414// Import resolution — best-effort path mapping
415// ---------------------------------------------------------------------------
416
417/// Attempt to resolve a raw import specifier to a relative file path.
418/// This is best-effort: we normalize relative paths and convert module
419/// paths to plausible file paths. Returns `None` for unresolvable imports
420/// (e.g. standard library modules).
421fn resolve_import(raw: &str, _base_dir: &Path) -> Option<PathBuf> {
422    let trimmed = raw.trim();
423    if trimmed.is_empty() {
424        return None;
425    }
426
427    // Skip standard library / external package imports that aren't file paths
428    // Heuristic: if it starts with `.` or `/` or contains a file extension, treat as path
429    if trimmed.starts_with('.')
430        || trimmed.starts_with('/')
431        || trimmed.contains(".rs")
432        || trimmed.contains(".py")
433        || trimmed.contains(".js")
434        || trimmed.contains(".ts")
435        || trimmed.contains(".go")
436        || trimmed.contains(".rb")
437        || trimmed.contains(".h")
438        || trimmed.contains(".c")
439        || trimmed.contains(".css")
440        || trimmed.contains(".html")
441    {
442        let path = PathBuf::from(trimmed);
443        return Some(path);
444    }
445
446    // For Rust crate:: and super:: paths, convert to relative path
447    if trimmed.starts_with("crate::") || trimmed.starts_with("super::") {
448        let converted = trimmed
449            .replace("crate::", "src/")
450            .replace("super::", "../")
451            .replace("::", "/");
452        return Some(PathBuf::from(converted));
453    }
454
455    // For module-style imports (e.g. `foo.bar` in Python/Java), convert dots to slashes
456    if trimmed.contains('.') && !trimmed.contains('/') {
457        let converted = trimmed.replace('.', "/");
458        return Some(PathBuf::from(converted));
459    }
460
461    // For C/C++ includes with path separators
462    if trimmed.contains('/') {
463        return Some(PathBuf::from(trimmed));
464    }
465
466    // Single-word imports (could be stdlib) — still track them for graph completeness
467    Some(PathBuf::from(trimmed))
468}
469
470// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_new_mapper_is_empty() {
480        let mapper = DependencyMapper::new();
481        assert_eq!(mapper.file_count(), 0);
482        assert!(mapper.dependencies_of(Path::new("foo.rs")).is_empty());
483        assert!(mapper.dependents_of(Path::new("foo.rs")).is_empty());
484    }
485
486    #[test]
487    fn test_add_rust_file() {
488        let mut mapper = DependencyMapper::new();
489        let source = r#"
490use crate::engine;
491use crate::types;
492use std::collections::HashMap;
493
494pub fn main() {}
495"#;
496        mapper.add_file(Path::new("src/lib.rs"), source);
497        let deps = mapper.dependencies_of(Path::new("src/lib.rs"));
498        assert!(deps.iter().any(|p| p.to_string_lossy().contains("engine")));
499        assert!(deps.iter().any(|p| p.to_string_lossy().contains("types")));
500    }
501
502    #[test]
503    fn test_add_python_file() {
504        let mut mapper = DependencyMapper::new();
505        let source = r#"
506import os
507from typing import List
508from .utils import helper
509"#;
510        mapper.add_file(Path::new("app/main.py"), source);
511        let deps = mapper.dependencies_of(Path::new("app/main.py"));
512        assert!(!deps.is_empty());
513        assert!(deps.iter().any(|p| p.to_string_lossy() == "os"));
514    }
515
516    #[test]
517    fn test_add_javascript_file() {
518        let mut mapper = DependencyMapper::new();
519        let source = r#"
520import React from 'react';
521import { useState } from 'react';
522const fs = require('fs');
523import './styles.css';
524"#;
525        mapper.add_file(Path::new("src/app.js"), source);
526        let deps = mapper.dependencies_of(Path::new("src/app.js"));
527        assert!(deps.iter().any(|p| p.to_string_lossy() == "react"));
528        assert!(deps.iter().any(|p| p.to_string_lossy() == "fs"));
529        assert!(deps.iter().any(|p| p.to_string_lossy() == "./styles.css"));
530    }
531
532    #[test]
533    fn test_add_typescript_file() {
534        let mut mapper = DependencyMapper::new();
535        let source = r#"
536import { Component } from '@angular/core';
537import { MyService } from './my-service';
538export { default } from './utils';
539"#;
540        mapper.add_file(Path::new("src/app.ts"), source);
541        let deps = mapper.dependencies_of(Path::new("src/app.ts"));
542        assert!(deps.iter().any(|p| p.to_string_lossy() == "@angular/core"));
543        assert!(deps.iter().any(|p| p.to_string_lossy() == "./my-service"));
544        assert!(deps.iter().any(|p| p.to_string_lossy() == "./utils"));
545    }
546
547    #[test]
548    fn test_add_go_file() {
549        let mut mapper = DependencyMapper::new();
550        let source = r#"
551package main
552
553import "fmt"
554import "os"
555"#;
556        mapper.add_file(Path::new("main.go"), source);
557        let deps = mapper.dependencies_of(Path::new("main.go"));
558        assert!(deps.iter().any(|p| p.to_string_lossy() == "fmt"));
559        assert!(deps.iter().any(|p| p.to_string_lossy() == "os"));
560    }
561
562    #[test]
563    fn test_add_java_file() {
564        let mut mapper = DependencyMapper::new();
565        let source = r#"
566import java.util.List;
567import com.example.MyClass;
568"#;
569        mapper.add_file(Path::new("src/Main.java"), source);
570        let deps = mapper.dependencies_of(Path::new("src/Main.java"));
571        assert!(deps.iter().any(|p| p.to_string_lossy().contains("java/util/List")));
572        assert!(deps.iter().any(|p| p.to_string_lossy().contains("com/example/MyClass")));
573    }
574
575    #[test]
576    fn test_add_c_file() {
577        let mut mapper = DependencyMapper::new();
578        let source = r#"
579#include <stdio.h>
580#include "myheader.h"
581"#;
582        mapper.add_file(Path::new("src/main.c"), source);
583        let deps = mapper.dependencies_of(Path::new("src/main.c"));
584        assert!(deps.iter().any(|p| p.to_string_lossy() == "stdio.h"));
585        assert!(deps.iter().any(|p| p.to_string_lossy() == "myheader.h"));
586    }
587
588    #[test]
589    fn test_add_ruby_file() {
590        let mut mapper = DependencyMapper::new();
591        let source = r#"
592require 'json'
593require_relative 'helper'
594"#;
595        mapper.add_file(Path::new("lib/app.rb"), source);
596        let deps = mapper.dependencies_of(Path::new("lib/app.rb"));
597        assert!(deps.iter().any(|p| p.to_string_lossy() == "json"));
598        assert!(deps.iter().any(|p| p.to_string_lossy() == "helper"));
599    }
600
601    #[test]
602    fn test_add_csharp_file() {
603        let mut mapper = DependencyMapper::new();
604        let source = r#"
605using System;
606using System.Collections.Generic;
607"#;
608        mapper.add_file(Path::new("src/Program.cs"), source);
609        let deps = mapper.dependencies_of(Path::new("src/Program.cs"));
610        assert!(deps.iter().any(|p| p.to_string_lossy() == "System"));
611        assert!(deps.iter().any(|p| p.to_string_lossy().contains("System/Collections/Generic")));
612    }
613
614    #[test]
615    fn test_add_swift_file() {
616        let mut mapper = DependencyMapper::new();
617        let source = "import Foundation\nimport UIKit\n";
618        mapper.add_file(Path::new("Sources/App.swift"), source);
619        let deps = mapper.dependencies_of(Path::new("Sources/App.swift"));
620        assert!(deps.iter().any(|p| p.to_string_lossy() == "Foundation"));
621        assert!(deps.iter().any(|p| p.to_string_lossy() == "UIKit"));
622    }
623
624    #[test]
625    fn test_add_kotlin_file() {
626        let mut mapper = DependencyMapper::new();
627        let source = "import com.example.MyClass\nimport kotlin.collections.List\n";
628        mapper.add_file(Path::new("src/Main.kt"), source);
629        let deps = mapper.dependencies_of(Path::new("src/Main.kt"));
630        assert!(deps.iter().any(|p| p.to_string_lossy().contains("com/example/MyClass")));
631    }
632
633    #[test]
634    fn test_add_css_file() {
635        let mut mapper = DependencyMapper::new();
636        let source = "@import 'reset.css';\n@import url('theme.css');\n";
637        mapper.add_file(Path::new("styles/main.css"), source);
638        let deps = mapper.dependencies_of(Path::new("styles/main.css"));
639        assert!(deps.iter().any(|p| p.to_string_lossy().contains("reset.css")));
640    }
641
642    #[test]
643    fn test_add_html_file() {
644        let mut mapper = DependencyMapper::new();
645        let source = r#"
646<link href="styles.css" rel="stylesheet">
647<script src="app.js"></script>
648"#;
649        mapper.add_file(Path::new("index.html"), source);
650        let deps = mapper.dependencies_of(Path::new("index.html"));
651        assert!(deps.iter().any(|p| p.to_string_lossy() == "styles.css"));
652        assert!(deps.iter().any(|p| p.to_string_lossy() == "app.js"));
653    }
654
655    #[test]
656    fn test_reverse_dependencies() {
657        let mut mapper = DependencyMapper::new();
658
659        mapper.add_file(
660            Path::new("src/a.rs"),
661            "use crate::shared;\n",
662        );
663        mapper.add_file(
664            Path::new("src/b.rs"),
665            "use crate::shared;\n",
666        );
667
668        let revs = mapper.dependents_of(Path::new("src/shared"));
669        assert_eq!(revs.len(), 2);
670    }
671
672    #[test]
673    fn test_remove_file_cleans_edges() {
674        let mut mapper = DependencyMapper::new();
675        mapper.add_file(
676            Path::new("src/a.rs"),
677            "use crate::b;\n",
678        );
679        assert!(!mapper.dependencies_of(Path::new("src/a.rs")).is_empty());
680
681        mapper.remove_file(Path::new("src/a.rs"));
682        assert!(mapper.dependencies_of(Path::new("src/a.rs")).is_empty());
683        assert_eq!(mapper.file_count(), 0);
684    }
685
686    #[test]
687    fn test_incremental_update() {
688        let mut mapper = DependencyMapper::new();
689        mapper.add_file(Path::new("src/a.rs"), "use crate::old_dep;\n");
690        assert!(mapper
691            .dependencies_of(Path::new("src/a.rs"))
692            .iter()
693            .any(|p| p.to_string_lossy().contains("old_dep")));
694
695        // Re-add with different imports
696        mapper.add_file(Path::new("src/a.rs"), "use crate::new_dep;\n");
697        let deps = mapper.dependencies_of(Path::new("src/a.rs"));
698        assert!(deps.iter().any(|p| p.to_string_lossy().contains("new_dep")));
699        assert!(!deps.iter().any(|p| p.to_string_lossy().contains("old_dep")));
700    }
701
702    #[test]
703    fn test_summary_empty() {
704        let mapper = DependencyMapper::new();
705        assert_eq!(mapper.summary(Path::new("foo.rs")), "");
706    }
707
708    #[test]
709    fn test_summary_with_deps() {
710        let mut mapper = DependencyMapper::new();
711        mapper.add_file(
712            Path::new("src/main.rs"),
713            "use crate::engine;\nuse crate::types;\n",
714        );
715        let summary = mapper.summary(Path::new("src/main.rs"));
716        assert!(summary.starts_with("[deps]"));
717        assert!(summary.contains("imports:"));
718    }
719
720    #[test]
721    fn test_summary_with_reverse_deps() {
722        let mut mapper = DependencyMapper::new();
723        mapper.add_file(Path::new("src/a.rs"), "use crate::shared;\n");
724        mapper.add_file(Path::new("src/b.rs"), "use crate::shared;\n");
725
726        let summary = mapper.summary(Path::new("src/shared"));
727        assert!(summary.contains("imported by:"));
728    }
729
730    #[test]
731    fn test_detect_language_extensions() {
732        assert_eq!(detect_language(Path::new("foo.rs")).as_deref(), Some("rust"));
733        assert_eq!(detect_language(Path::new("foo.py")).as_deref(), Some("python"));
734        assert_eq!(detect_language(Path::new("foo.js")).as_deref(), Some("javascript"));
735        assert_eq!(detect_language(Path::new("foo.ts")).as_deref(), Some("typescript"));
736        assert_eq!(detect_language(Path::new("foo.go")).as_deref(), Some("go"));
737        assert_eq!(detect_language(Path::new("foo.java")).as_deref(), Some("java"));
738        assert_eq!(detect_language(Path::new("foo.c")).as_deref(), Some("c"));
739        assert_eq!(detect_language(Path::new("foo.cpp")).as_deref(), Some("cpp"));
740        assert_eq!(detect_language(Path::new("foo.rb")).as_deref(), Some("ruby"));
741        assert_eq!(detect_language(Path::new("foo.cs")).as_deref(), Some("csharp"));
742        assert_eq!(detect_language(Path::new("foo.kt")).as_deref(), Some("kotlin"));
743        assert_eq!(detect_language(Path::new("foo.swift")).as_deref(), Some("swift"));
744        assert_eq!(detect_language(Path::new("foo.css")).as_deref(), Some("css"));
745        assert_eq!(detect_language(Path::new("foo.html")).as_deref(), Some("html"));
746        assert_eq!(detect_language(Path::new("foo.sh")).as_deref(), Some("bash"));
747        assert_eq!(detect_language(Path::new("foo.toml")).as_deref(), Some("toml"));
748        assert_eq!(detect_language(Path::new("foo.yaml")).as_deref(), Some("yaml"));
749        assert_eq!(detect_language(Path::new("foo.json")).as_deref(), Some("json"));
750        assert_eq!(detect_language(Path::new("foo.xyz")), None);
751    }
752
753    #[test]
754    fn test_unknown_extension_no_imports() {
755        let mut mapper = DependencyMapper::new();
756        mapper.add_file(Path::new("data.xyz"), "some random content\n");
757        assert!(mapper.dependencies_of(Path::new("data.xyz")).is_empty());
758    }
759
760    #[test]
761    fn test_cpp_includes() {
762        let mut mapper = DependencyMapper::new();
763        let source = "#include <iostream>\n#include \"mylib.h\"\n";
764        mapper.add_file(Path::new("src/main.cpp"), source);
765        let deps = mapper.dependencies_of(Path::new("src/main.cpp"));
766        assert!(deps.iter().any(|p| p.to_string_lossy() == "iostream"));
767        assert!(deps.iter().any(|p| p.to_string_lossy() == "mylib.h"));
768    }
769
770    #[test]
771    fn test_python_from_import() {
772        let mut mapper = DependencyMapper::new();
773        let source = "from os.path import join\nfrom . import utils\n";
774        mapper.add_file(Path::new("app/main.py"), source);
775        let deps = mapper.dependencies_of(Path::new("app/main.py"));
776        assert!(deps.iter().any(|p| p.to_string_lossy().contains("os")));
777        assert!(deps.iter().any(|p| p.to_string_lossy() == "."));
778    }
779
780    #[test]
781    fn test_default_trait() {
782        let mapper = DependencyMapper::default();
783        assert_eq!(mapper.file_count(), 0);
784    }
785
786    #[test]
787    fn test_file_count() {
788        let mut mapper = DependencyMapper::new();
789        mapper.add_file(Path::new("a.rs"), "use crate::b;\n");
790        mapper.add_file(Path::new("b.rs"), "use crate::c;\n");
791        assert_eq!(mapper.file_count(), 2);
792        mapper.remove_file(Path::new("a.rs"));
793        assert_eq!(mapper.file_count(), 1);
794    }
795
796    #[test]
797    fn test_csharp_using_var_skipped() {
798        let mut mapper = DependencyMapper::new();
799        let source = "using System;\nusing var stream = new FileStream();\n";
800        mapper.add_file(Path::new("Program.cs"), source);
801        let deps = mapper.dependencies_of(Path::new("Program.cs"));
802        // Should have System but not the `using var` statement
803        assert!(deps.iter().any(|p| p.to_string_lossy() == "System"));
804        assert!(!deps.iter().any(|p| p.to_string_lossy().contains("stream")));
805    }
806
807    #[test]
808    fn test_js_require_and_import() {
809        let mut mapper = DependencyMapper::new();
810        let source = r#"
811import defaultExport from './module';
812const path = require('path');
813"#;
814        mapper.add_file(Path::new("index.js"), source);
815        let deps = mapper.dependencies_of(Path::new("index.js"));
816        assert!(deps.iter().any(|p| p.to_string_lossy() == "./module"));
817        assert!(deps.iter().any(|p| p.to_string_lossy() == "path"));
818    }
819
820    #[test]
821    fn test_short_name() {
822        assert_eq!(short_name(Path::new("a.rs")), "a.rs");
823        assert_eq!(short_name(Path::new("src/lib.rs")), "src/lib.rs");
824        assert_eq!(short_name(Path::new("foo/bar/baz.rs")), "bar/baz.rs");
825    }
826}