Skip to main content

kiss/graph/
dependency_graph_body.rs

1pub struct DependencyGraph {
2    pub graph: DiGraph<String, ()>,
3    pub nodes: HashMap<String, NodeIndex>,
4    pub paths: HashMap<String, PathBuf>,
5    pub path_to_module: HashMap<PathBuf, String>,
6}
7
8#[derive(Debug, Default)]
9pub struct ModuleGraphMetrics {
10    pub fan_in: usize,
11    pub fan_out: usize,
12    pub indirect_dependencies: usize,
13    pub dependency_depth: usize,
14}
15
16#[derive(Debug)]
17pub struct CycleInfo {
18    pub cycles: Vec<Vec<String>>,
19}
20
21impl DependencyGraph {
22    pub fn new() -> Self {
23        Self {
24            graph: DiGraph::new(),
25            nodes: HashMap::new(),
26            paths: HashMap::new(),
27            path_to_module: HashMap::new(),
28        }
29    }
30
31    pub fn get_or_create_node(&mut self, name: &str) -> NodeIndex {
32        if let Some(&idx) = self.nodes.get(name) {
33            idx
34        } else {
35            let idx = self.graph.add_node(name.to_string());
36            self.nodes.insert(name.to_string(), idx);
37            idx
38        }
39    }
40
41    pub fn add_dependency(&mut self, from: &str, to: &str) {
42        if from == to {
43            return;
44        }
45        let from_idx = self.get_or_create_node(from);
46        let to_idx = self.get_or_create_node(to);
47        if !self.graph.contains_edge(from_idx, to_idx) {
48            self.graph.add_edge(from_idx, to_idx, ());
49        }
50    }
51
52    pub fn module_metrics(&self, module: &str) -> ModuleGraphMetrics {
53        let Some(&idx) = self.nodes.get(module) else {
54            return ModuleGraphMetrics::default();
55        };
56        let fan_out = self
57            .graph
58            .neighbors_directed(idx, petgraph::Direction::Outgoing)
59            .count();
60        let (total_reachable, depth) = self.compute_reachable_and_depth(idx);
61        ModuleGraphMetrics {
62            fan_in: self
63                .graph
64                .neighbors_directed(idx, petgraph::Direction::Incoming)
65                .count(),
66            fan_out,
67            indirect_dependencies: total_reachable.saturating_sub(fan_out),
68            dependency_depth: depth,
69        }
70    }
71
72    /// BFS from `start`, returning (`total_reachable`, `max_depth`).
73    /// `total_reachable` counts all nodes reachable at depth >= 1 (excludes start itself).
74    pub(crate) fn compute_reachable_and_depth(&self, start: NodeIndex) -> (usize, usize) {
75        use std::collections::{HashSet, VecDeque};
76        let mut visited = HashSet::new();
77        let mut queue = VecDeque::new();
78        let mut max_depth = 0;
79        visited.insert(start);
80        queue.push_back((start, 0));
81        while let Some((node, depth)) = queue.pop_front() {
82            for neighbor in self
83                .graph
84                .neighbors_directed(node, petgraph::Direction::Outgoing)
85            {
86                if visited.insert(neighbor) {
87                    max_depth = max_depth.max(depth + 1);
88                    queue.push_back((neighbor, depth + 1));
89                }
90            }
91        }
92        (visited.len() - 1, max_depth)
93    }
94
95    pub(crate) fn is_cycle(&self, scc: &[NodeIndex]) -> bool {
96        match scc.len() {
97            0 => false,
98            1 => self.graph.contains_edge(scc[0], scc[0]),
99            _ => true,
100        }
101    }
102
103    pub fn find_cycles(&self) -> CycleInfo {
104        CycleInfo {
105            cycles: tarjan_scc(&self.graph)
106                .into_iter()
107                .filter(|scc| self.is_cycle(scc))
108                .map(|scc| scc.into_iter().map(|idx| self.graph[idx].clone()).collect())
109                .collect(),
110        }
111    }
112
113    /// Returns the qualified module name for a path, if the path is in this graph.
114    pub fn module_for_path(&self, path: &std::path::Path) -> Option<String> {
115        self.path_to_module.get(path).cloned()
116    }
117
118    /// Returns test modules that import the given module (directly).
119    /// Used for coverage: "candidate" tests that could cover definitions in `module`.
120    pub fn test_importers_of(&self, module: &str) -> Vec<String> {
121        let Some(&idx) = self.nodes.get(module) else {
122            return Vec::new();
123        };
124        self.graph
125            .neighbors_directed(idx, Direction::Incoming)
126            .map(|i| self.graph[i].clone())
127            .filter(|m| is_test_module(self, m))
128            .collect()
129    }
130
131    /// True if `from_module` has a direct edge to `to_module` (`from_module` imports `to_module`).
132    pub fn imports(&self, from_module: &str, to_module: &str) -> bool {
133        let (Some(&from_idx), Some(&to_idx)) =
134            (self.nodes.get(from_module), self.nodes.get(to_module))
135        else {
136            return false;
137        };
138        self.graph.find_edge(from_idx, to_idx).is_some()
139    }
140}
141
142impl Default for DependencyGraph {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148pub(crate) fn is_test_module(graph: &DependencyGraph, module_name: &str) -> bool {
149    let Some(p) = graph.paths.get(module_name) else {
150        return false;
151    };
152    p.components()
153        .any(|c| c.as_os_str() == OsStr::new("tests") || c.as_os_str() == OsStr::new("test"))
154}
155
156// --- module naming (merged from former `names.rs`) ---
157pub(crate) fn file_stem_str(path: &Path) -> &str {
158    path.file_stem()
159        .map_or("unknown", |s| s.to_str().unwrap_or("unknown"))
160}
161pub(crate) fn parent_dir_strings(path: &Path) -> Vec<String> {
162    path.parent()
163        .map(|p| {
164            p.components()
165                .filter_map(|c| match c {
166                    Component::Normal(os) => os.to_str().map(std::string::ToString::to_string),
167                    Component::CurDir => Some(".".to_string()),
168                    _ => None,
169                })
170                .collect()
171        })
172        .unwrap_or_default()
173}
174pub(crate) fn trim_src_suffix(mut dirs: Vec<String>) -> Vec<String> {
175    if let Some(pos) = dirs.iter().rposition(|d| d == "src") {
176        dirs = dirs[(pos + 1)..].to_vec();
177    }
178    dirs.retain(|d| d != ".");
179    dirs
180}
181pub(crate) fn join_qualified_dirs_and_stem(dirs: &[String], stem: &str) -> String {
182    format!("{}.{}", dirs.join("."), stem)
183}
184/// Qualified module id from path (dirs + stem; `pkg/__init__.py` → `pkg`).
185pub fn qualified_module_name(path: &Path) -> String {
186    let stem = file_stem_str(path);
187    let dirs = trim_src_suffix(parent_dir_strings(path));
188    if stem == "__init__" {
189        return if dirs.is_empty() {
190            stem.to_string()
191        } else {
192            dirs.join(".")
193        };
194    }
195    if dirs.is_empty() {
196        stem.to_string()
197    } else {
198        join_qualified_dirs_and_stem(&dirs, stem)
199    }
200}
201pub(crate) fn bare_module_name(path: &Path) -> String {
202    let stem = path.file_stem().map_or_else(
203        || "unknown".to_string(),
204        |s| s.to_string_lossy().into_owned(),
205    );
206    if stem == "__init__" {
207        path.parent()
208            .and_then(|p| p.file_name())
209            .map_or(stem, |p| p.to_string_lossy().into_owned())
210    } else {
211        stem
212    }
213}
214pub fn is_entry_point(name: &str) -> bool {
215    let bare = name.rsplit('.').next().unwrap_or(name);
216    if name == "tests" || name.starts_with("tests.") || name.contains(".tests.") {
217        return true;
218    }
219    if name == "bin" || name.starts_with("bin.") || name.contains(".bin.") {
220        return true;
221    }
222    matches!(
223        bare,
224        "main" | "lib" | "build" | "__main__" | "__init__" | "tests" | "conftest" | "setup"
225    ) || bare.starts_with("test_")
226        || bare.ends_with("_test")
227        || bare.contains("_integration")
228        || bare.contains("_bench")
229}
230pub(crate) fn is_orphan(fan_in: usize, fan_out: usize, module_name: &str) -> bool {
231    fan_in == 0 && fan_out == 0 && !is_entry_point(module_name)
232}
233
234/// Rust crate roots (`src/lib.rs`, `src/main.rs`, `src/build.rs`) aggregate the whole crate;
235/// their indirect-dependency counts reflect project size, not a single module's coupling.
236pub(crate) fn is_crate_root_aggregator(graph: &DependencyGraph, module_name: &str) -> bool {
237    let Some(path) = graph.paths.get(module_name) else {
238        return false;
239    };
240    let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
241        return false;
242    };
243    if !matches!(file_name, "lib.rs" | "main.rs" | "build.rs") {
244        return false;
245    }
246    path.parent().is_some_and(|parent| {
247        parent
248            .file_name()
249            .is_some_and(|d| d == OsStr::new("src") || d == OsStr::new("tests"))
250    })
251}