use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::ingest::parser::{Entity, FileInsight, ParserRegistry};
use super::diff::GitDiffResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityChangeKind {
Added,
Removed,
SignatureChanged,
BodyChanged,
}
#[derive(Debug, Clone)]
pub struct EntityChange {
pub file: PathBuf,
pub entity_name: String,
pub kind: EntityChangeKind,
pub old_range: Option<(usize, usize)>,
pub new_range: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Default)]
pub struct EntityChangeSet {
pub changes: Vec<EntityChange>,
}
impl EntityChangeSet {
pub fn has_interface_change(&self) -> bool {
self.changes.iter().any(|c| match c.kind {
EntityChangeKind::Added
| EntityChangeKind::Removed
| EntityChangeKind::SignatureChanged => true,
EntityChangeKind::BodyChanged => false,
})
}
}
pub fn no_entity_change_files(
changed_files: &[PathBuf],
entity_changes: &EntityChangeSet,
root: &crate::project::ProjectRoot,
) -> std::collections::HashSet<PathBuf> {
if entity_changes.changes.is_empty() {
return std::collections::HashSet::new();
}
changed_files
.iter()
.filter(|f| {
root.path().join(f).exists()
&& !entity_changes
.changes
.iter()
.any(|c| c.file.as_path() == f.as_path())
})
.cloned()
.collect()
}
pub fn classify_entity_changes_at(
root: &crate::project::ProjectRoot,
diff: &GitDiffResult,
current_insights: &[FileInsight],
) -> Result<EntityChangeSet> {
if diff.from_commit.is_empty() {
return Ok(EntityChangeSet::default());
}
let repo = git2::Repository::open(root.path())
.with_context(|| "实体级变化分类需要 Git 仓库")?;
let from_commit = repo.find_commit(git2::Oid::from_str(&diff.from_commit)?)?;
let from_tree = from_commit.tree()?;
let registry = ParserRegistry::new();
let current: HashMap<String, Vec<Entity>> = current_insights
.iter()
.map(|i| {
let rel = std::path::Path::new(&i.path)
.strip_prefix(root.path())
.unwrap_or(std::path::Path::new(&i.path));
(super::norm_sep(&rel.to_string_lossy()), i.entities.clone())
})
.collect();
let mut set = EntityChangeSet::default();
for path in &diff.modified {
let old_entities = read_old_entities(&repo, &from_tree, path, ®istry)?;
let new_entities = current
.get(&super::norm_sep(&path.to_string_lossy()))
.cloned()
.unwrap_or_default();
compare_entities(&mut set, path, &old_entities, &new_entities);
}
for path in &diff.added {
if let Some(ents) = current.get(&super::norm_sep(&path.to_string_lossy())) {
for e in ents {
set.changes.push(EntityChange {
file: path.clone(),
entity_name: e.name.clone(),
kind: EntityChangeKind::Added,
old_range: None,
new_range: Some((e.line_start, e.line_end)),
});
}
}
}
for path in &diff.deleted {
for e in read_old_entities(&repo, &from_tree, path, ®istry)? {
set.changes.push(EntityChange {
file: path.clone(),
entity_name: e.name.clone(),
kind: EntityChangeKind::Removed,
old_range: Some((e.line_start, e.line_end)),
new_range: None,
});
}
}
Ok(set)
}
fn compare_entities(
set: &mut EntityChangeSet,
path: &Path,
old: &[Entity],
new: &[Entity],
) {
let old_by_name: HashMap<&str, Vec<&Entity>> = group_by_name(old);
let new_by_name: HashMap<&str, Vec<&Entity>> = group_by_name(new);
for (name, entries) in &new_by_name {
if !old_by_name.contains_key(*name) {
for e in entries {
set.changes.push(EntityChange {
file: path.to_path_buf(),
entity_name: e.name.clone(),
kind: EntityChangeKind::Added,
old_range: None,
new_range: Some((e.line_start, e.line_end)),
});
}
}
}
for (name, entries) in &old_by_name {
if !new_by_name.contains_key(*name) {
for e in entries {
set.changes.push(EntityChange {
file: path.to_path_buf(),
entity_name: e.name.clone(),
kind: EntityChangeKind::Removed,
old_range: Some((e.line_start, e.line_end)),
new_range: None,
});
}
}
}
for (name, old_entries) in &old_by_name {
if let Some(new_entries) = new_by_name.get(*name) {
let old_sigs: Vec<String> = old_entries
.iter()
.map(|e| normalize_sig(e.signature.as_deref()))
.collect();
let new_sigs: Vec<String> = new_entries
.iter()
.map(|e| normalize_sig(e.signature.as_deref()))
.collect();
let kind = if old_sigs == new_sigs {
EntityChangeKind::BodyChanged
} else {
EntityChangeKind::SignatureChanged
};
for (old_e, new_e) in old_entries.iter().zip(new_entries.iter()) {
let unchanged = kind == EntityChangeKind::BodyChanged
&& old_e.line_start == new_e.line_start
&& old_e.line_end == new_e.line_end
&& normalize_sig(old_e.signature.as_deref())
== normalize_sig(new_e.signature.as_deref());
if unchanged {
continue;
}
set.changes.push(EntityChange {
file: path.to_path_buf(),
entity_name: (*name).to_string(),
kind,
old_range: Some((old_e.line_start, old_e.line_end)),
new_range: Some((new_e.line_start, new_e.line_end)),
});
}
}
}
}
fn normalize_sig(sig: Option<&str>) -> String {
sig.unwrap_or("")
.chars()
.filter(|c| !c.is_whitespace())
.collect()
}
fn read_old_entities(
repo: &git2::Repository,
from_tree: &git2::Tree,
path: &Path,
registry: &ParserRegistry,
) -> Result<Vec<Entity>> {
let entry = match from_tree.get_path(path) {
Ok(e) => e,
Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let obj = entry
.to_object(repo)
.with_context(|| format!("读取 {} 旧版本失败", path.display()))?;
let blob = obj
.into_blob()
.map_err(|_| anyhow::anyhow!("{} 在 from_commit 中不是 blob", path.display()))?;
let content = match std::str::from_utf8(blob.content()) {
Ok(c) => c,
Err(_) => return Ok(Vec::new()),
};
Ok(match registry.get_for_file(path) {
Some(parser) => parser.parse(content, path)?.entities,
None => Vec::new(),
})
}
fn group_by_name(entities: &[Entity]) -> HashMap<&str, Vec<&Entity>> {
let mut map: HashMap<&str, Vec<&Entity>> = HashMap::new();
for e in entities {
map.entry(e.name.as_str()).or_default().push(e);
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entity(name: &str, sig: &str, start: usize, end: usize) -> Entity {
Entity {
name: name.into(),
kind: "fn".into(),
line_start: start,
line_end: end,
doc_comment: None,
signature: Some(sig.into()), visibility: None,
}
}
#[test]
fn test_compare_added_and_removed() {
let mut set = EntityChangeSet::default();
let old = vec![make_entity("gone", "fn gone()", 1, 2)];
let new = vec![make_entity("fresh", "fn fresh()", 5, 6)];
compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
assert_eq!(set.changes.len(), 2);
assert_eq!(set.changes[0].kind, EntityChangeKind::Added);
assert_eq!(set.changes[1].kind, EntityChangeKind::Removed);
assert!(set.has_interface_change());
}
#[test]
fn test_compare_signature_changed() {
let mut set = EntityChangeSet::default();
let old = vec![make_entity("f", "fn f(a: i32)", 1, 3)];
let new = vec![make_entity("f", "fn f(a: i32, b: i32)", 1, 4)];
compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
assert_eq!(set.changes.len(), 1);
assert_eq!(set.changes[0].kind, EntityChangeKind::SignatureChanged);
assert!(set.has_interface_change());
}
#[test]
fn test_compare_body_changed_only() {
let mut set = EntityChangeSet::default();
let old = vec![make_entity("f", "fn f()", 1, 3)];
let new = vec![make_entity("f", "fn f()", 1, 5)];
compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
assert_eq!(set.changes.len(), 1);
assert_eq!(set.changes[0].kind, EntityChangeKind::BodyChanged);
assert!(!set.has_interface_change());
}
#[test]
fn test_normalize_sig_ignores_whitespace() {
let a = "fn f( a : i32 )";
let b = "fn f(a: i32)";
assert_eq!(normalize_sig(Some(a)), normalize_sig(Some(b)));
}
}