use std::collections::HashMap;
use petgraph::visit::{EdgeRef, IntoEdgeReferences, IntoNodeReferences};
use leiden_rs::{GraphDataBuilder, Leiden, LeidenConfig, QualityType};
use crate::model::*;
const LEIDEN_SEED: u64 = 42;
const LEIDEN_RESOLUTION: f64 = 0.5;
pub const WEIGHT_IMPORTS: f64 = 0.8;
pub const WEIGHT_CALLS: f64 = 0.7;
fn file_dir_key(graph: &KnowledgeGraph, nid: NodeId) -> String {
let path = graph
.graph
.node_weight(nid)
.and_then(|n| n.file_path.as_deref())
.unwrap_or("");
let norm = path.replace('\\', "/");
let dir = norm.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
let dir = dir.trim_end_matches('/');
if dir.is_empty() {
"<root>".to_string()
} else {
dir.to_string()
}
}
pub fn detect_communities(graph: &KnowledgeGraph) -> Vec<Vec<NodeId>> {
detect_communities_with_resolution(graph, LEIDEN_RESOLUTION)
}
pub fn detect_communities_with_resolution(graph: &KnowledgeGraph, resolution: f64) -> Vec<Vec<NodeId>> {
let file_nodes: Vec<NodeId> = graph
.graph
.node_references()
.filter(|(_, n)| n.kind == NodeKind::File)
.map(|(id, _)| id)
.collect();
if file_nodes.is_empty() {
return Vec::new();
}
if file_nodes.len() == 1 {
return vec![file_nodes];
}
const MIN_DIRS_FOR_SUPERNODE: usize = 24;
let mut dirs: std::collections::BTreeMap<String, Vec<NodeId>> = std::collections::BTreeMap::new();
for &nid in &file_nodes {
let d = file_dir_key(graph, nid);
dirs.entry(d).or_default().push(nid);
}
if dirs.len() <= 1 || dirs.len() >= MIN_DIRS_FOR_SUPERNODE {
return dirs.into_values().collect();
}
let compact: HashMap<NodeId, usize> = file_nodes
.iter()
.enumerate()
.map(|(i, &nid)| (nid, i))
.collect();
let mut entity_to_file: HashMap<NodeId, NodeId> = HashMap::new();
for edge in graph.graph.edge_references() {
let kind = graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
if kind == Some(EdgeKind::Contains) && compact.contains_key(&edge.source()) {
entity_to_file.insert(edge.target(), edge.source());
}
}
let file_of = |nid: NodeId| -> Option<NodeId> {
if compact.contains_key(&nid) {
Some(nid)
} else {
entity_to_file.get(&nid).copied()
}
};
let mut edge_weights: HashMap<(usize, usize), f64> = HashMap::new();
for edge in graph.graph.edge_references() {
let e = graph
.graph
.edge_weight(edge.id())
.expect("边权重必然存在");
let w = match e.kind {
EdgeKind::Imports => WEIGHT_IMPORTS,
EdgeKind::Calls => WEIGHT_CALLS,
_ => continue, };
let (Some(sf), Some(tf)) = (file_of(edge.source()), file_of(edge.target())) else {
continue; };
let (Some(&si), Some(&ti)) = (compact.get(&sf), compact.get(&tf)) else {
continue;
};
if si == ti {
continue; }
*edge_weights.entry((si, ti)).or_insert(0.0) += w;
}
if edge_weights.is_empty() {
return file_nodes.into_iter().map(|nid| vec![nid]).collect();
}
let mut builder = GraphDataBuilder::new(file_nodes.len()).directed();
for ((s, t), w) in &edge_weights {
builder
.add_edge(*s, *t, *w)
.expect("边权重均为有限非负数");
}
let data = builder.build().expect("图数据构造失败");
let config = LeidenConfig {
quality: QualityType::CPM,
resolution,
seed: Some(LEIDEN_SEED),
..Default::default()
};
let result = Leiden::new(config)
.run(&data)
.expect("Leiden 社区检测失败");
let membership = result.partition.as_slice();
let mut groups: HashMap<usize, Vec<NodeId>> = HashMap::new();
for (i, &comm) in membership.iter().enumerate() {
groups.entry(comm).or_default().push(file_nodes[i]);
}
let mut communities: Vec<Vec<NodeId>> = groups.into_values().collect();
communities.sort_by(|a, b| {
b.len()
.cmp(&a.len())
.then_with(|| min_file_path(graph, a).cmp(&min_file_path(graph, b)))
});
communities
}
fn min_file_path(graph: &KnowledgeGraph, files: &[NodeId]) -> String {
files
.iter()
.filter_map(|nid| graph.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
.min()
.unwrap_or_default()
}
pub fn community_name(files: &[String], fallback_index: usize) -> String {
if files.is_empty() {
return format!("module_{fallback_index}");
}
let dirs: Vec<Vec<String>> = files.iter().map(|p| dir_segments(p)).collect();
let min_len = dirs.iter().map(|d| d.len()).min().unwrap_or(0);
let mut common = 0usize;
'outer: for i in 0..min_len {
let seg = &dirs[0][i];
for other in &dirs[1..] {
if other.get(i) != Some(seg) {
break 'outer;
}
}
common = i + 1;
}
if common > 0 {
return dirs[0][..common].join("::");
}
let mut dir_counts: HashMap<String, usize> = HashMap::new();
for d in &dirs {
let key = if d.is_empty() {
"<root>".to_string()
} else {
d.join("::")
};
*dir_counts.entry(key).or_insert(0) += 1;
}
if let Some((best, _)) = dir_counts
.into_iter()
.max_by_key(|(name, count)| (*count, name.clone()))
&& best != "<root>"
{
return best;
}
format!("module_{fallback_index}")
}
fn dir_segments(path: &str) -> Vec<String> {
use std::path::Component;
std::path::Path::new(path)
.parent()
.map(|p| {
p.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_graph() -> KnowledgeGraph {
let mut kg = KnowledgeGraph::default();
let g = &mut kg.graph;
let add_file =
|g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
path: &str|
-> (NodeId, NodeId) {
let module_path: Vec<String> = dir_segments(path);
let nid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::File,
name: path.into(),
file_path: Some(path.into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path,
});
let eid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::Function,
name: format!("f{}", nid.index()),
file_path: Some(path.into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: Vec::new(),
});
g.add_edge(
nid,
eid,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Contains,
source: nid,
target: eid,
weight: 1.0,
location: None,
},
);
(nid, eid)
};
let (_a, ea) = add_file(g, "src/a.rs");
let (_b, eb) = add_file(g, "src/b.rs");
let _tcp = add_file(g, "src/net/tcp.rs");
g.add_edge(
ea,
eb,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Calls,
source: ea,
target: eb,
weight: 0.7,
location: None,
},
);
kg
}
#[test]
fn test_detect_communities_basic() {
let kg = make_graph();
let communities = detect_communities(&kg);
assert_eq!(communities.len(), 2, "应产出 2 个社区");
let ab = communities
.iter()
.find(|c| c.len() == 2)
.expect("应存在含 2 文件的社区");
let paths: Vec<String> = ab
.iter()
.map(|nid| kg.graph.node_weight(*nid).unwrap().file_path.clone().unwrap())
.collect();
assert!(paths.contains(&"src/a.rs".to_string()));
assert!(paths.contains(&"src/b.rs".to_string()));
}
#[test]
fn test_detect_communities_empty_graph() {
let kg = KnowledgeGraph::default();
assert!(detect_communities(&kg).is_empty());
}
#[test]
fn test_detect_communities_single_file() {
let mut kg = KnowledgeGraph::default();
let g = &mut kg.graph;
g.add_node(CodeNode {
id: NodeId::new(0),
kind: NodeKind::File,
name: "src/main.rs".into(),
file_path: Some("src/main.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["src".into()],
});
let communities = detect_communities(&kg);
assert_eq!(communities.len(), 1);
assert_eq!(communities[0].len(), 1);
}
#[test]
fn test_community_name_common_prefix() {
let files = vec!["src/net/tcp.rs".to_string(), "src/net/udp.rs".to_string()];
assert_eq!(community_name(&files, 0), "src::net");
}
#[test]
fn test_community_name_single_file() {
let files = vec!["src/config.rs".to_string()];
assert_eq!(community_name(&files, 3), "src");
}
#[test]
fn test_community_name_most_populated_dir() {
let files = vec![
"app/main.rs".to_string(),
"app/util.rs".to_string(),
"lib/helper.rs".to_string(),
];
assert_eq!(community_name(&files, 1), "app");
}
#[test]
fn test_community_name_fallback() {
let files = vec!["main.rs".to_string()];
assert_eq!(community_name(&files, 7), "module_7");
assert_eq!(community_name(&[], 2), "module_2");
}
#[test]
fn test_detect_communities_stable_order() {
let mut kg = KnowledgeGraph::default();
let g = &mut kg.graph;
let add_comm = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
path: &str| {
let nid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::File,
name: path.into(),
file_path: Some(path.into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: dir_segments(path),
});
let eid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::Function,
name: format!("f{}", nid.index()),
file_path: Some(path.into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: Vec::new(),
});
g.add_edge(
nid,
eid,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Contains,
source: nid,
target: eid,
weight: 1.0,
location: None,
},
);
};
add_comm(g, "src/net/tcp.rs");
add_comm(g, "src/net/udp.rs");
add_comm(g, "src/util.rs");
let f = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>, path: &str| {
g.node_indices()
.find(|n| g.node_weight(*n).map(|n| n.name == path).unwrap_or(false))
.unwrap()
};
let _tcp = f(g, "src/net/tcp.rs");
let _udp = f(g, "src/net/udp.rs");
let fns: Vec<_> = g
.node_indices()
.filter(|n| g.node_weight(*n).map(|n| n.kind == NodeKind::Function).unwrap_or(false))
.collect();
g.add_edge(
fns[0],
fns[1],
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Calls,
source: fns[0],
target: fns[1],
weight: 0.7,
location: None,
},
);
let communities = detect_communities(&kg);
assert_eq!(communities.len(), 2);
assert_eq!(communities[0].len(), 2, "大社区应排前(稳定重排序)");
assert_eq!(communities[1].len(), 1);
}
fn make_dirs_graph(
n_dirs: usize,
files_per_dir: usize,
connected_pairs: &[(usize, usize)],
) -> KnowledgeGraph {
let mut kg = KnowledgeGraph::default();
let g = &mut kg.graph;
let mut first_entity: HashMap<String, NodeId> = HashMap::new();
for d in 0..n_dirs {
let dir = format!("dir{d:02}");
for f in 0..files_per_dir {
let path = format!("{dir}/f{f}.rs");
let nid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::File,
name: path.clone(),
file_path: Some(path.clone()),
line_range: None,
doc_comment: None,
signature: None,
visibility: None,
module_path: dir_segments(&path),
});
let eid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::Function,
name: format!("f{f}"),
file_path: Some(path),
line_range: None,
doc_comment: None,
signature: None,
visibility: None,
module_path: Vec::new(),
});
g.add_edge(
nid,
eid,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Contains,
source: nid,
target: eid,
weight: 1.0,
location: None,
},
);
first_entity.entry(dir.clone()).or_insert(eid);
}
}
for (a, b) in connected_pairs {
let ea = first_entity[&format!("dir{a:02}")];
let eb = first_entity[&format!("dir{b:02}")];
g.add_edge(
ea,
eb,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Calls,
source: ea,
target: eb,
weight: 0.7,
location: None,
},
);
}
kg
}
#[test]
fn test_detect_communities_entity_level_below_threshold() {
let kg = make_dirs_graph(20, 2, &[(0, 1), (1, 2), (3, 4)]);
let first = detect_communities(&kg);
let second = detect_communities(&kg);
assert_eq!(first, second, "同图两次划分必须完全一致(确定性)");
assert!(
first.len() > 20,
"实体级划分社区数应多于目录数(独立目录每文件一社区), 实际 {} 个社区",
first.len()
);
let mixed = first.iter().any(|c| {
let dirs_in: std::collections::HashSet<&str> = c
.iter()
.filter_map(|nid| kg.graph.node_weight(*nid).and_then(|n| n.file_path.as_deref()))
.filter_map(|p| p.rsplit_once('/').map(|(d, _)| d))
.collect();
dirs_in.len() > 1
});
assert!(mixed, "跨目录调用链应产生混合目录社区, 实际: {:?}", first);
}
#[test]
fn test_detect_communities_dir_level_at_and_above_threshold() {
for (n_dirs, files_per_dir) in [(24usize, 3usize), (30, 2), (40, 3)] {
let pairs: Vec<(usize, usize)> = (0..n_dirs - 1).map(|a| (a, a + 1)).collect();
let kg = make_dirs_graph(n_dirs, files_per_dir, &pairs);
let first = detect_communities(&kg);
let second = detect_communities(&kg);
assert_eq!(first, second, "{n_dirs} 目录两次划分必须一致(确定性)");
assert_eq!(
first.len(),
n_dirs,
"{n_dirs} 目录应产出 {n_dirs} 个社区, 实际 {}",
first.len()
);
for comm in &first {
let mut paths: Vec<String> = comm
.iter()
.filter_map(|nid| kg.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
.collect();
paths.sort();
assert_eq!(paths.len(), files_per_dir, "每社区应为单目录全部文件: {paths:?}");
let dirs_in: std::collections::HashSet<&str> = paths
.iter()
.filter_map(|p| p.rsplit_once('/').map(|(d, _)| d))
.collect();
assert_eq!(dirs_in.len(), 1, "社区内文件必须同属一个目录: {paths:?}");
}
}
}
#[test]
fn test_detect_communities_single_dir_repo() {
let kg = make_dirs_graph(1, 10, &[]);
let first = detect_communities(&kg);
let second = detect_communities(&kg);
assert_eq!(first, second, "单目录仓库两次划分必须一致(确定性)");
assert_eq!(
first.len(),
1,
"单目录仓库应产出 1 个社区(整库一个模块), 实际 {} 个",
first.len()
);
assert_eq!(first[0].len(), 10, "社区应包含全部 10 个文件");
let all_in_dir00 = first[0].iter().all(|nid| {
kg.graph
.node_weight(*nid)
.and_then(|n| n.file_path.as_deref())
.is_some_and(|p| p.starts_with("dir00/"))
});
assert!(all_in_dir00, "社区内所有文件必须同属唯一目录");
let mut kg2 = KnowledgeGraph::default();
let g = &mut kg2.graph;
for i in 0..5 {
let path = format!("main{i}.rs");
let nid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::File,
name: path.clone(),
file_path: Some(path),
line_range: None,
doc_comment: None,
signature: None,
visibility: None,
module_path: Vec::new(),
});
let eid = g.add_node(CodeNode {
id: NodeId::new(g.node_count()),
kind: NodeKind::Function,
name: format!("f{i}"),
file_path: None,
line_range: None,
doc_comment: None,
signature: None,
visibility: None,
module_path: Vec::new(),
});
g.add_edge(
nid,
eid,
CodeEdge {
id: EdgeId::new(g.edge_count()),
kind: EdgeKind::Contains,
source: nid,
target: eid,
weight: 1.0,
location: None,
},
);
}
let comms = detect_communities(&kg2);
assert_eq!(comms.len(), 1, "根目录散文件仓库也应整体一社区");
assert_eq!(comms[0].len(), 5);
}
}