1use std::collections::HashSet;
2
3use petgraph::visit::{EdgeRef, IntoEdgeReferences};
4
5
6use crate::model::*;
7
8use super::community::{community_name, detect_communities};
9
10pub struct ModuleDetector<'a> {
12 graph: &'a KnowledgeGraph,
13}
14
15impl<'a> ModuleDetector<'a> {
16 pub fn new(graph: &'a KnowledgeGraph) -> Self {
17 Self { graph }
18 }
19
20 pub fn detect(&self) -> Vec<ModuleCluster> {
31 let communities = detect_communities(self.graph);
32 let mut clusters: Vec<ModuleCluster> = Vec::with_capacity(communities.len());
33 let mut used_names: HashSet<String> = HashSet::new();
35
36 for (idx, community) in communities.iter().enumerate() {
37 let mut file_paths: Vec<String> = community
40 .iter()
41 .filter_map(|nid| {
42 self.graph
43 .graph
44 .node_weight(*nid)
45 .and_then(|n| n.file_path.clone())
46 })
47 .collect();
48 file_paths.sort();
49 let mut name = community_name(&file_paths, idx);
50 if used_names.contains(&name) {
51 if let Some(stem) = file_stem(&file_paths) {
53 let alt = format!("{name}::{stem}");
54 if !used_names.contains(&alt) {
55 name = alt;
56 } else {
57 name = format!("module_{idx}");
58 }
59 } else {
60 name = format!("module_{idx}");
61 }
62 }
63 used_names.insert(name.clone());
64
65 let cohesion = self.calculate_cohesion(community);
66 let coupling = self.calculate_coupling(community);
67
68 let file_set: HashSet<NodeId> = community.iter().copied().collect();
73 let mut expanded: HashSet<NodeId> = file_set.clone();
74 for edge in self.graph.graph.edge_references() {
75 let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
76 if kind == Some(EdgeKind::Contains) && file_set.contains(&edge.source()) {
77 expanded.insert(edge.target());
78 }
79 }
80 let mut unique: Vec<NodeId> = expanded.into_iter().collect();
82 unique.sort();
83 clusters.push(ModuleCluster {
84 name,
85 node_ids: unique,
86 cohesion,
87 coupling,
88 description: None,
89 });
90 }
91
92 clusters
93 }
94
95 fn calculate_cohesion(&self, node_ids: &[NodeId]) -> f64 {
97 let (internal, external) = self.count_edges(node_ids);
98 let total = internal + external;
99 if total == 0.0 {
100 return 0.0;
101 }
102 internal / total
103 }
104
105 fn calculate_coupling(&self, node_ids: &[NodeId]) -> f64 {
107 let (internal, external) = self.count_edges(node_ids);
108 let total = internal + external;
109 if total == 0.0 {
110 return 0.0;
111 }
112 external / total
113 }
114
115 fn count_edges(&self, node_ids: &[NodeId]) -> (f64, f64) {
125 let file_set: HashSet<NodeId> = node_ids.iter().copied().collect();
127 let mut set: HashSet<NodeId> = file_set.clone();
128 for edge in self.graph.graph.edge_references() {
129 let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
130 if kind == Some(EdgeKind::Contains) && file_set.contains(&edge.source()) {
131 set.insert(edge.target());
132 }
133 }
134
135 let mut internal = 0.0;
137 let mut external = 0.0;
138 for edge in self.graph.graph.edge_references() {
139 let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
140 if kind == Some(EdgeKind::Contains) {
141 continue;
142 }
143 let s = edge.source();
144 let t = edge.target();
145 let in_s = set.contains(&s);
146 let in_t = set.contains(&t);
147 if in_s && in_t {
148 internal += 1.0;
149 } else if in_s || in_t {
150 external += 1.0;
151 }
152 }
153 (internal, external)
154 }
155}
156
157fn file_stem(files: &[String]) -> Option<String> {
159 files
160 .first()
161 .and_then(|p| std::path::Path::new(p).file_stem())
162 .map(|s| s.to_string_lossy().into_owned())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169
170 fn make_small_graph() -> KnowledgeGraph {
171 let mut kg = KnowledgeGraph::default();
172 let g = &mut kg.graph;
173 let p = g.add_node(CodeNode {
174 id: NodeId::new(0), kind: NodeKind::Project, name: "p".into(),
175 file_path: None, line_range: None, doc_comment: None,
176 signature: None, module_path: vec![], visibility: None,
177 });
178 let m = g.add_node(CodeNode {
179 id: NodeId::new(1), kind: NodeKind::Module, name: "m".into(),
180 file_path: None, line_range: None, doc_comment: None,
181 signature: None, module_path: vec!["src".into()], visibility: None,
182 });
183 let f1 = g.add_node(CodeNode {
184 id: NodeId::new(2), kind: NodeKind::File, name: "a.rs".into(),
185 file_path: Some("src/a.rs".into()), line_range: None, doc_comment: None,
186 signature: None, module_path: vec!["src".into()], visibility: None,
187 });
188 let f2 = g.add_node(CodeNode {
189 id: NodeId::new(3), kind: NodeKind::File, name: "b.rs".into(),
190 file_path: Some("src/b.rs".into()), line_range: None, doc_comment: None,
191 signature: None, module_path: vec!["src".into()], visibility: None,
192 });
193 let e1 = g.add_node(CodeNode {
194 id: NodeId::new(4), kind: NodeKind::Function, name: "foo".into(),
195 file_path: Some("src/a.rs".into()), line_range: None, doc_comment: None,
196 signature: None, module_path: vec!["src".into(), "a".into()], visibility: None,
197 });
198 let e2 = g.add_node(CodeNode {
199 id: NodeId::new(5), kind: NodeKind::Function, name: "bar".into(),
200 file_path: Some("src/b.rs".into()), line_range: None, doc_comment: None,
201 signature: None, module_path: vec!["src".into(), "b".into()], visibility: None,
202 });
203 for (src, tgt) in &[(p, m), (m, f1), (m, f2), (f1, e1), (f2, e2)] {
205 g.add_edge(*src, *tgt, CodeEdge {
206 id: EdgeId::new(g.edge_count()), kind: EdgeKind::Contains,
207 source: *src, target: *tgt, weight: 1.0, location: None,
208 });
209 }
210 g.add_edge(e1, e2, CodeEdge {
212 id: EdgeId::new(g.edge_count()), kind: EdgeKind::Calls,
213 source: e1, target: e2, weight: 0.7, location: None,
214 });
215 kg
216 }
217
218 #[test]
219 fn test_cohesion() {
220 let kg = make_small_graph();
221 let detector = ModuleDetector::new(&kg);
222 let ids = vec![NodeId::new(2), NodeId::new(3)];
224 let c = detector.calculate_cohesion(&ids);
225 let expected = 1.0;
229 assert!((c - expected).abs() < 1e-6);
230 }
231
232 #[test]
233 fn test_coupling() {
234 let mut kg = make_small_graph();
235 kg.graph.add_edge(
237 NodeId::new(4), NodeId::new(5),
238 CodeEdge {
239 id: EdgeId::new(kg.graph.edge_count()), kind: EdgeKind::Calls,
240 source: NodeId::new(4), target: NodeId::new(5), weight: 0.5, location: None,
241 },
242 );
243 let detector = ModuleDetector::new(&kg);
244 let ids = vec![NodeId::new(2)]; let coupling = detector.calculate_coupling(&ids);
246 dbg!(coupling);
249 assert!((coupling - 1.0).abs() < 1e-6);
250 }
251
252 #[test]
253 fn test_detect() {
254 let kg = make_small_graph();
255 let detector = ModuleDetector::new(&kg);
256 let clusters = detector.detect();
257 assert_eq!(clusters.len(), 1, "应检出 src 模块,实际: {:?}", clusters.iter().map(|c| &c.name).collect::<Vec<_>>());
260 assert_eq!(clusters[0].name, "src");
261 assert_eq!(clusters[0].node_ids.len(), 4, "模块应包含 2 文件 + 2 实体节点");
264 let kinds: Vec<_> = clusters[0]
266 .node_ids
267 .iter()
268 .map(|nid| kg.graph.node_weight(*nid).unwrap().kind.clone())
269 .collect();
270 assert_eq!(
271 kinds.iter().filter(|k| **k == NodeKind::File).count(),
272 2,
273 "应含 2 个文件节点: {:?}",
274 kinds
275 );
276 assert_eq!(
277 kinds.iter().filter(|k| **k == NodeKind::Function).count(),
278 2,
279 "应含 2 个实体节点: {:?}",
280 kinds
281 );
282 }
283
284 #[test]
287 fn test_detect_multiple_directories() {
288 let mut kg = KnowledgeGraph::default();
289 let g = &mut kg.graph;
290 let add_file = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>, id: usize, path: &str, segs: Vec<&str>| -> (NodeId, NodeId) {
291 let nid = g.add_node(CodeNode {
292 id: NodeId::new(id), kind: NodeKind::File, name: path.into(),
293 file_path: Some(path.into()), line_range: None, doc_comment: None,
294 signature: None, module_path: segs.iter().map(|s| s.to_string()).collect(), visibility: None,
295 });
296 let eid = g.add_node(CodeNode {
298 id: NodeId::new(id + 100), kind: NodeKind::Function, name: format!("f{id}"),
299 file_path: Some(path.into()), line_range: None, doc_comment: None,
300 signature: None, module_path: segs.iter().map(|s| s.to_string()).collect(), visibility: None,
301 });
302 g.add_edge(nid, eid, CodeEdge {
303 id: EdgeId::new(g.edge_count()), kind: EdgeKind::Contains,
304 source: nid, target: eid, weight: 1.0, location: None,
305 });
306 (nid, eid)
307 };
308 let (_tcp, etcp) = add_file(g, 0, "src/net/tcp.rs", vec!["src", "net"]);
309 let (_udp, eudp) = add_file(g, 1, "src/net/udp.rs", vec!["src", "net"]);
310 let _server = add_file(g, 2, "src/http/server.rs", vec!["src", "http"]);
311 let _client = add_file(g, 3, "src/http/client.rs", vec!["src", "http"]);
312 g.add_edge(etcp, eudp, CodeEdge {
314 id: EdgeId::new(g.edge_count()), kind: EdgeKind::Calls,
315 source: etcp, target: eudp, weight: 0.7, location: None,
316 });
317
318 let detector = ModuleDetector::new(&kg);
319 let clusters = detector.detect();
320 let names: Vec<&str> = clusters.iter().map(|c| c.name.as_str()).collect();
321 assert!(
323 names.contains(&"src::net"),
324 "应检出 src::net 社区,实际: {names:?}"
325 );
326 assert!(
327 names.contains(&"src::http"),
328 "应检出 src::http 单文件社区,实际: {names:?}"
329 );
330 let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
332 assert_eq!(unique.len(), names.len(), "模块名必须唯一: {names:?}");
333 let net = clusters.iter().find(|c| c.name == "src::net").unwrap();
335 assert_eq!(net.node_ids.len(), 4, "src::net 应含 2 文件 + 2 实体");
336 }
337}