use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ChangeKind {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemChange {
pub name: String,
pub kind: ChangeKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuralDiff {
pub changes: Vec<ItemChange>,
}
impl StructuralDiff {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.changes.is_empty()
}
#[must_use]
pub fn changed_names(&self) -> BTreeSet<String> {
self.changes.iter().map(|c| c.name.clone()).collect()
}
}
pub type ItemDigestMap = BTreeMap<String, String>;
#[must_use]
pub fn item_digest_map(file: &syn::File) -> ItemDigestMap {
let mut map = ItemDigestMap::new();
for item in &file.items {
if let Some((name, digest)) = named_item_digest(item) {
map.insert(name, digest);
}
}
map
}
#[must_use]
pub fn item_digest_map_multi(files: &[syn::File]) -> ItemDigestMap {
let mut map = ItemDigestMap::new();
for file in files {
map.extend(item_digest_map(file));
}
map
}
#[must_use]
pub fn diff_item_digests(before: &ItemDigestMap, after: &ItemDigestMap) -> StructuralDiff {
let mut changes = Vec::new();
let all_names: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
for name in all_names {
let kind = match (before.get(name), after.get(name)) {
(Some(db), Some(da)) if db == da => continue, (Some(_), Some(_)) => ChangeKind::Modified,
(None, Some(_)) => ChangeKind::Added,
(Some(_), None) => ChangeKind::Removed,
(None, None) => unreachable!("name came from one of the two maps"),
};
changes.push(ItemChange {
name: name.clone(),
kind,
});
}
StructuralDiff { changes }
}
pub fn scan_diff_files(before_src: &str, after_src: &str) -> syn::Result<StructuralDiff> {
let before = item_digest_map(&syn::parse_file(before_src)?);
let after = item_digest_map(&syn::parse_file(after_src)?);
Ok(diff_item_digests(&before, &after))
}
fn named_item_digest(item: &syn::Item) -> Option<(String, String)> {
use antigen_fingerprint::structural_digest;
match item {
syn::Item::Fn(i) => Some((i.sig.ident.to_string(), structural_digest(i))),
syn::Item::Struct(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Enum(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Union(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Trait(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Type(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Const(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Static(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::Mod(i) => Some((i.ident.to_string(), structural_digest(i))),
syn::Item::TraitAlias(i) => Some((i.ident.to_string(), structural_digest(i))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const GUARD_BEFORE: &str = r"
pub fn validate(i: usize, len: usize) -> bool {
if i >= len { return false; }
true
}
pub fn helper(x: u8) -> u8 { x.wrapping_add(1) }
";
const GUARD_AFTER_REMOVED: &str = r"
pub fn validate(i: usize, len: usize) -> bool {
true
}
pub fn helper(x: u8) -> u8 { x.wrapping_add(1) }
";
const REORDERED_BENIGN: &str = r"
// a comment inserted above
pub fn helper(x: u8) -> u8 { x.wrapping_add(1) }
pub fn validate(i: usize, len: usize) -> bool {
if i >= len { return false; }
true
}
";
#[test]
fn guard_removal_surfaces_the_modified_item() {
let diff = scan_diff_files(GUARD_BEFORE, GUARD_AFTER_REMOVED).unwrap();
assert_eq!(
diff.changed_names(),
BTreeSet::from(["validate".to_string()])
);
let change = &diff.changes[0];
assert_eq!(change.name, "validate");
assert_eq!(change.kind, ChangeKind::Modified);
}
#[test]
fn benign_reorder_surfaces_nothing() {
let diff = scan_diff_files(GUARD_BEFORE, REORDERED_BENIGN).unwrap();
assert!(
diff.is_empty(),
"a benign reorder must surface no structural change; got {:?}",
diff.changes
);
}
#[test]
fn added_and_removed_items_are_classified() {
let before = "pub fn a() {} pub fn b() {}";
let after = "pub fn a() {} pub fn c() {}";
let diff = scan_diff_files(before, after).unwrap();
let mut kinds: Vec<(String, ChangeKind)> = diff
.changes
.iter()
.map(|c| (c.name.clone(), c.kind))
.collect();
kinds.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
kinds,
vec![
("b".to_string(), ChangeKind::Removed),
("c".to_string(), ChangeKind::Added),
]
);
}
#[test]
fn discriminates_real_change_from_benign_motion() {
let real = scan_diff_files(GUARD_BEFORE, GUARD_AFTER_REMOVED).unwrap();
let benign = scan_diff_files(GUARD_BEFORE, REORDERED_BENIGN).unwrap();
assert!(!real.is_empty());
assert!(benign.is_empty());
assert_ne!(real, benign);
}
#[test]
fn struct_field_change_surfaces() {
let before = "pub struct S { a: u8 }";
let after = "pub struct S { a: u8, b: u16 }";
let diff = scan_diff_files(before, after).unwrap();
assert_eq!(diff.changed_names(), BTreeSet::from(["S".to_string()]));
assert_eq!(diff.changes[0].kind, ChangeKind::Modified);
}
#[test]
fn serializes_co_natively() {
let diff = scan_diff_files(GUARD_BEFORE, GUARD_AFTER_REMOVED).unwrap();
let json = serde_json::to_string(&diff).expect("serializes");
assert!(json.contains("\"kind\":\"modified\""));
let back: StructuralDiff = serde_json::from_str(&json).unwrap();
assert_eq!(back, diff);
}
}