kiss/graph/
dependency_graph_body.rs1pub 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 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 pub fn module_for_path(&self, path: &std::path::Path) -> Option<String> {
115 self.path_to_module.get(path).cloned()
116 }
117
118 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 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
156pub(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}
184pub 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
234pub(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}