use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use petgraph::visit::{EdgeRef, IntoNodeIdentifiers};
use crate::model::{EdgeKind, KnowledgeGraph, NodeId};
use super::change::{EntityChangeKind, EntityChangeSet};
pub fn propagate_impact(changed_files: &[PathBuf], graph: &KnowledgeGraph, max_depth: usize) -> Vec<String> {
if graph.graph.node_count() == 0 {
return Vec::new();
}
let file_paths: Vec<String> = changed_files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let start_nodes = find_start_nodes(&file_paths, graph);
if start_nodes.is_empty() {
return Vec::new();
}
let mut result: Vec<String> = propagate_from(start_nodes, graph, max_depth).into_iter().collect();
result.sort();
result
}
pub fn module_files(affected_modules: &[String], graph: &KnowledgeGraph) -> Vec<PathBuf> {
let target: HashSet<&str> = affected_modules.iter().map(|s| s.as_str()).collect();
let mut files: Vec<PathBuf> = graph
.graph
.node_identifiers()
.filter_map(|nid| {
let node = &graph.graph[nid];
let mp = node.module_path.join("::");
if target.contains(mp.as_str()) {
node.file_path.as_ref().map(PathBuf::from)
} else {
None
}
})
.collect();
files.sort();
files.dedup();
files
}
pub fn propagate_impact_semantic(
changed_files: &[PathBuf],
entity_changes: &EntityChangeSet,
graph: &KnowledgeGraph,
max_depth: usize,
) -> Vec<String> {
if graph.graph.node_count() == 0 {
return Vec::new();
}
if entity_changes.changes.is_empty() {
return propagate_impact(changed_files, graph, max_depth);
}
let interface_files: HashSet<String> = entity_changes
.changes
.iter()
.filter(|c| matches!(c.kind, EntityChangeKind::Added | EntityChangeKind::Removed | EntityChangeKind::SignatureChanged))
.map(|c| c.file.to_string_lossy().to_string())
.collect();
let mut affected: HashSet<String> = HashSet::new();
for file in changed_files {
let fp = file.to_string_lossy().to_string();
let start_nodes = find_start_nodes(std::slice::from_ref(&fp), graph);
if start_nodes.is_empty() {
continue;
}
if interface_files.contains(&fp) {
affected.extend(propagate_from(start_nodes, graph, max_depth));
} else {
for nid in start_nodes {
let module = graph.graph[nid].module_path.join("::");
if !module.is_empty() {
affected.insert(module);
}
}
}
}
let mut result: Vec<String> = affected.into_iter().collect();
result.sort();
result
}
fn find_start_nodes(file_paths: &[String], graph: &KnowledgeGraph) -> Vec<NodeId> {
let normalized: Vec<String> = file_paths.iter().map(|p| super::norm_sep(p)).collect();
graph
.graph
.node_identifiers()
.filter(|&nid| {
let node = &graph.graph[nid];
node.file_path
.as_ref()
.map(|fp| {
let fp = super::norm_sep(fp);
normalized.iter().any(|cfp| fp.contains(cfp.as_str()))
})
.unwrap_or(false)
})
.collect()
}
fn propagate_from(start_nodes: Vec<NodeId>, graph: &KnowledgeGraph, max_depth: usize) -> HashSet<String> {
let mut affected: HashSet<String> = HashSet::new();
for &start in &start_nodes {
let mut visited: HashSet<NodeId> = HashSet::new();
let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
queue.push_back((start, 0));
visited.insert(start);
let start_node = &graph.graph[start];
if !start_node.module_path.is_empty() {
affected.insert(start_node.module_path.join("::"));
}
while let Some((current, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
for edge in graph.graph.edges(current).chain(
graph.graph.edges_directed(current, petgraph::Direction::Incoming),
) {
let neighbor = if edge.source() == current {
edge.target()
} else {
edge.source()
};
if visited.contains(&neighbor) {
continue;
}
let kind = &graph.graph[edge.id()].kind;
if !matches!(kind, EdgeKind::Imports | EdgeKind::Calls) {
continue;
}
let neighbor_node = &graph.graph[neighbor];
if !neighbor_node.module_path.is_empty() {
affected.insert(neighbor_node.module_path.join("::"));
}
visited.insert(neighbor);
queue.push_back((neighbor, depth + 1));
}
}
}
affected
}
#[cfg(test)]
mod tests {
use super::*;
use petgraph::stable_graph::StableDiGraph;
use crate::model::{CodeEdge, CodeNode, NodeKind};
fn make_simple_graph() -> KnowledgeGraph {
let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
let core = g.add_node(CodeNode {
id: NodeId::new(0),
kind: NodeKind::Module,
name: "core".into(),
file_path: Some("src/core.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["core".into()],
});
let net = g.add_node(CodeNode {
id: NodeId::new(1),
kind: NodeKind::Module,
name: "net".into(),
file_path: Some("src/net.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["net".into()],
});
let db = g.add_node(CodeNode {
id: NodeId::new(2),
kind: NodeKind::Module,
name: "db".into(),
file_path: Some("src/db.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["db".into()],
});
g.add_edge(
core, net,
CodeEdge {
id: petgraph::stable_graph::EdgeIndex::new(0),
kind: EdgeKind::Imports,
source: core,
target: net,
weight: 1.0,
location: None,
},
);
g.add_edge(
net, db,
CodeEdge {
id: petgraph::stable_graph::EdgeIndex::new(1),
kind: EdgeKind::Imports,
source: net,
target: db,
weight: 1.0,
location: None,
},
);
KnowledgeGraph {
graph: g,
modules: vec![],
features: Vec::new(),
}
}
#[test]
fn test_propagate_impact() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let affected = propagate_impact(&changed, &graph, 3);
assert!(affected.contains(&"db".to_string()));
assert!(affected.contains(&"net".to_string()));
assert!(affected.contains(&"core".to_string()));
}
#[test]
fn test_no_impact_for_unknown_file() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("unknown.rs")];
let affected = propagate_impact(&changed, &graph, 3);
assert!(affected.is_empty());
}
#[test]
fn test_empty_graph() {
let graph = KnowledgeGraph::default();
let changed = vec![PathBuf::from("src/main.rs")];
let affected = propagate_impact(&changed, &graph, 3);
assert!(affected.is_empty());
}
#[test]
fn test_impact_reverse_propagation() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let affected = propagate_impact(&changed, &graph, 3);
assert!(affected.contains(&"db".to_string()));
assert!(affected.contains(&"net".to_string()));
assert!(affected.contains(&"core".to_string()));
assert_eq!(affected.len(), 3);
}
#[test]
fn test_impact_no_duplicate_modules() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let affected = propagate_impact(&changed, &graph, 3);
let unique: std::collections::HashSet<_> = affected.iter().cloned().collect();
assert_eq!(affected.len(), unique.len());
}
#[test]
fn test_semantic_body_change_only_local() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let changes = EntityChangeSet {
changes: vec![crate::incremental::change::EntityChange {
file: PathBuf::from("src/db.rs"),
entity_name: "load".into(),
kind: crate::incremental::change::EntityChangeKind::BodyChanged,
old_range: Some((1, 5)),
new_range: Some((1, 8)),
}],
};
let affected = propagate_impact_semantic(&changed, &changes, &graph, 3);
assert_eq!(affected, vec!["db".to_string()]);
}
#[test]
fn test_semantic_signature_change_propagates() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let changes = EntityChangeSet {
changes: vec![crate::incremental::change::EntityChange {
file: PathBuf::from("src/db.rs"),
entity_name: "load".into(),
kind: crate::incremental::change::EntityChangeKind::SignatureChanged,
old_range: Some((1, 5)),
new_range: Some((1, 5)),
}],
};
let affected = propagate_impact_semantic(&changed, &changes, &graph, 3);
assert!(affected.contains(&"db".to_string()));
assert!(affected.contains(&"net".to_string()));
assert!(affected.contains(&"core".to_string()));
}
#[test]
fn test_semantic_removed_propagates() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let changes = EntityChangeSet {
changes: vec![crate::incremental::change::EntityChange {
file: PathBuf::from("src/db.rs"),
entity_name: "load".into(),
kind: crate::incremental::change::EntityChangeKind::Removed,
old_range: Some((1, 5)),
new_range: None,
}],
};
let affected = propagate_impact_semantic(&changed, &changes, &graph, 3);
assert!(affected.contains(&"net".to_string()), "删除应传播到导入方");
assert!(affected.contains(&"core".to_string()));
}
#[test]
fn test_semantic_empty_changes_falls_back() {
let graph = make_simple_graph();
let changed = vec![PathBuf::from("src/db.rs")];
let changes = EntityChangeSet::default();
let affected = propagate_impact_semantic(&changed, &changes, &graph, 3);
assert_eq!(affected.len(), 3, "空实体变化应回退双向传播");
}
#[test]
fn test_module_files_resolves_affected_modules() {
let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
for (i, (path, segs)) in [
("src/net.rs", vec!["net"]),
("src/db.rs", vec!["db"]),
("src/core.rs", vec!["core"]),
]
.into_iter()
.enumerate()
{
g.add_node(CodeNode {
id: NodeId::new(i),
kind: NodeKind::File,
name: path.into(),
file_path: Some(path.into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: segs.into_iter().map(|s| s.to_string()).collect(),
});
}
let graph = KnowledgeGraph { graph: g, modules: vec![], features: Vec::new() };
let files = module_files(&["net".into(), "db".into()], &graph);
assert_eq!(files.len(), 2, "应反查出 net.rs 与 db.rs");
assert!(files.contains(&PathBuf::from("src/net.rs")));
assert!(files.contains(&PathBuf::from("src/db.rs")));
assert!(module_files(&["not_exist".into()], &graph).is_empty());
assert!(module_files(&[], &graph).is_empty());
let files2 = module_files(&["net".into(), "net".into()], &graph);
assert_eq!(files2.len(), 1);
}
}