mod compare;
mod helpers;
mod migration;
mod relocate;
mod rename;
#[cfg(test)]
mod tests;
use crate::traits::LanguageSemantics;
use crate::types::{ApiSurface, ChangeSubject, StructuralChange, StructuralChangeType, Symbol};
use std::collections::{HashMap, HashSet};
use compare::diff_symbol;
use helpers::{kind_label, symbol_summary};
use migration::detect_migrations;
use relocate::{detect_relocations, RelocationType};
use rename::detect_renames;
pub fn diff_surfaces_with_semantics<M, S>(
old: &ApiSurface<M>,
new: &ApiSurface<M>,
semantics: &S,
) -> Vec<StructuralChange>
where
M: Default + Clone + PartialEq,
S: LanguageSemantics<M>,
{
let mut changes = Vec::new();
let old_symbols: Vec<&Symbol<M>> = old
.symbols
.iter()
.filter(|s| !semantics.should_skip_symbol(s))
.collect();
let new_symbols: Vec<&Symbol<M>> = new
.symbols
.iter()
.filter(|s| !semantics.should_skip_symbol(s))
.collect();
let old_map: HashMap<&str, &Symbol<M>> = old_symbols
.iter()
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let new_map: HashMap<&str, &Symbol<M>> = new_symbols
.iter()
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let removed: Vec<&Symbol<M>> = old_symbols
.iter()
.filter(|s| !new_map.contains_key(s.qualified_name.as_str()))
.copied()
.collect();
let added: Vec<&Symbol<M>> = new_symbols
.iter()
.filter(|s| !old_map.contains_key(s.qualified_name.as_str()))
.copied()
.collect();
let (relocations, _skip_removed, _skip_added) = detect_relocations(
&removed,
&added,
|qname| semantics.canonical_name_for_relocation(qname),
|old_qname, new_qname| {
if let Some(label) = semantics.classify_relocation(old_qname, new_qname) {
match label {
"moved to deprecated" => RelocationType::MovedToDeprecated,
"promoted from deprecated" => RelocationType::PromotedFromDeprecated,
"promoted from next" => RelocationType::PromotedFromNext,
"moved to next" => RelocationType::MovedToNext,
_ => RelocationType::Relocated,
}
} else {
RelocationType::Relocated
}
},
);
let relocated_old: HashSet<&str> = relocations
.iter()
.map(|r| r.old.qualified_name.as_str())
.collect();
let relocated_new: HashSet<&str> = relocations
.iter()
.map(|r| r.new.qualified_name.as_str())
.collect();
for reloc in &relocations {
match reloc.relocation_type {
RelocationType::MovedToDeprecated => {
changes.push(StructuralChange {
symbol: reloc.old.name.clone(),
qualified_name: reloc.old.qualified_name.clone(),
kind: reloc.old.kind,
package: reloc.old.package.clone(),
change_type: StructuralChangeType::Relocated {
from: ChangeSubject::Symbol {
kind: reloc.old.kind,
},
to: ChangeSubject::Symbol {
kind: reloc.old.kind,
},
},
before: Some(reloc.old.qualified_name.clone()),
after: Some(reloc.new.qualified_name.clone()),
description: format!(
"{} `{}` moved to deprecated exports",
kind_label(reloc.old.kind),
reloc.old.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
RelocationType::PromotedFromDeprecated => {
changes.push(StructuralChange {
symbol: reloc.old.name.clone(),
qualified_name: reloc.old.qualified_name.clone(),
kind: reloc.old.kind,
package: reloc.old.package.clone(),
change_type: StructuralChangeType::Added(ChangeSubject::Symbol {
kind: reloc.old.kind,
}),
before: Some(reloc.old.qualified_name.clone()),
after: Some(reloc.new.qualified_name.clone()),
description: format!(
"{} `{}` promoted from deprecated to main exports",
kind_label(reloc.old.kind),
reloc.old.name
),
is_breaking: false,
impact: None,
migration_target: None,
});
}
RelocationType::PromotedFromNext => {
changes.push(StructuralChange {
symbol: reloc.old.name.clone(),
qualified_name: reloc.old.qualified_name.clone(),
kind: reloc.old.kind,
package: reloc.old.package.clone(),
change_type: StructuralChangeType::Renamed {
from: ChangeSubject::Symbol { kind: reloc.old.kind },
to: ChangeSubject::Symbol { kind: reloc.new.kind },
},
before: Some(reloc.old.qualified_name.clone()),
after: Some(reloc.new.qualified_name.clone()),
description: format!(
"{} `{}` promoted from next (preview) to main exports — import path changed",
kind_label(reloc.old.kind),
reloc.old.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
RelocationType::MovedToNext => {
changes.push(StructuralChange {
symbol: reloc.old.name.clone(),
qualified_name: reloc.old.qualified_name.clone(),
kind: reloc.old.kind,
package: reloc.old.package.clone(),
change_type: StructuralChangeType::Renamed {
from: ChangeSubject::Symbol {
kind: reloc.old.kind,
},
to: ChangeSubject::Symbol {
kind: reloc.new.kind,
},
},
before: Some(reloc.old.qualified_name.clone()),
after: Some(reloc.new.qualified_name.clone()),
description: format!(
"{} `{}` moved to next (preview) exports — import path changed",
kind_label(reloc.old.kind),
reloc.old.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
RelocationType::Relocated => {
let old_import = reloc
.old
.import_path
.as_ref()
.or(reloc.old.package.as_ref());
let new_import = reloc
.new
.import_path
.as_ref()
.or(reloc.new.package.as_ref());
if old_import.is_some() && old_import != new_import {
let old_ip = old_import.cloned().unwrap_or_default();
let new_ip = new_import.cloned().unwrap_or_default();
changes.push(StructuralChange {
symbol: reloc.old.name.clone(),
qualified_name: reloc.old.qualified_name.clone(),
kind: reloc.old.kind,
package: reloc.old.package.clone(),
change_type: StructuralChangeType::Relocated {
from: ChangeSubject::Symbol {
kind: reloc.old.kind,
},
to: ChangeSubject::Symbol {
kind: reloc.new.kind,
},
},
before: Some(old_ip),
after: Some(new_ip),
description: format!(
"{} `{}` moved from `{}` to `{}`",
kind_label(reloc.old.kind),
reloc.old.name,
old_import.map(|s| s.as_str()).unwrap_or("?"),
new_import.map(|s| s.as_str()).unwrap_or("?"),
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
}
}
diff_symbol(reloc.old, reloc.new, &mut changes, semantics);
}
let new_by_name: HashMap<&str, Vec<&Symbol<M>>> = {
let mut map: HashMap<&str, Vec<&Symbol<M>>> = HashMap::new();
for s in new_symbols.iter() {
map.entry(s.name.as_str()).or_default().push(s);
}
map
};
let remaining_removed: Vec<&Symbol<M>> = removed
.iter()
.filter(|s| !relocated_old.contains(s.qualified_name.as_str()))
.filter(|s| {
let Some(new_syms) = new_by_name.get(s.name.as_str()) else {
return true;
};
let old_import = s.import_path.as_ref().or(s.package.as_ref());
let old_base = old_import.map(|p| semantics.canonical_name_for_relocation(p));
!new_syms.iter().any(|ns| {
let new_import = ns.import_path.as_ref().or(ns.package.as_ref());
let new_base = new_import.map(|p| semantics.canonical_name_for_relocation(p));
old_base == new_base
})
})
.copied()
.collect();
let remaining_added: Vec<&Symbol<M>> = added
.iter()
.filter(|s| !relocated_new.contains(s.qualified_name.as_str()))
.copied()
.collect();
let renames = detect_renames(
&remaining_removed,
&remaining_added,
|a, b| semantics.same_family(a, b),
semantics.primitive_type_names(),
);
let (validated_renames, _rejected_renames) = validate_renames(&renames, semantics);
let mut renamed_old: HashSet<&str> = validated_renames
.iter()
.map(|r| r.old.qualified_name.as_str())
.collect();
let mut renamed_new: HashSet<&str> = validated_renames
.iter()
.map(|r| r.new.qualified_name.as_str())
.collect();
let dir_renames: HashMap<String, Vec<String>> = {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for rm in &renames {
let old_dir = rm
.old
.file
.parent()
.map(|p| p.to_string_lossy().to_string());
let new_dir = rm
.new
.file
.parent()
.map(|p| p.to_string_lossy().to_string());
if let (Some(od), Some(nd)) = (old_dir, new_dir) {
if od != nd {
tracing::debug!(
old_dir = %od,
new_dir = %nd,
via = %rm.old.name,
"Directory rename mapping from Phase 2"
);
let targets = map.entry(od).or_default();
if !targets.contains(&nd) {
targets.push(nd);
}
}
}
}
map
};
for rm in &validated_renames {
if rm.old.name == rm.new.name {
let old_import = rm.old.import_path.as_ref().or(rm.old.package.as_ref());
let new_import = rm.new.import_path.as_ref().or(rm.new.package.as_ref());
if old_import.is_some() && old_import != new_import {
let old_ip = old_import.cloned().unwrap_or_default();
let new_ip = new_import.cloned().unwrap_or_default();
tracing::debug!(
name = %rm.old.name,
from = %old_ip,
to = %new_ip,
"Detected import path relocation (same name, different import path)"
);
changes.push(StructuralChange {
symbol: rm.old.name.clone(),
qualified_name: rm.old.qualified_name.clone(),
kind: rm.old.kind,
package: rm.old.package.clone(),
change_type: StructuralChangeType::Relocated {
from: ChangeSubject::Symbol { kind: rm.old.kind },
to: ChangeSubject::Symbol { kind: rm.new.kind },
},
before: Some(old_ip),
after: Some(new_ip),
description: format!(
"{} `{}` moved from `{}` to `{}`",
kind_label(rm.old.kind),
rm.old.name,
old_import.map(|s| s.as_str()).unwrap_or("?"),
new_import.map(|s| s.as_str()).unwrap_or("?"),
),
is_breaking: true,
impact: None,
migration_target: None,
});
diff_symbol(rm.old, rm.new, &mut changes, semantics);
} else {
tracing::trace!(
name = %rm.old.name,
from = %rm.old.qualified_name,
to = %rm.new.qualified_name,
"Skipping no-op rename (moved without import path change)"
);
diff_symbol(rm.old, rm.new, &mut changes, semantics);
}
continue;
}
changes.push(StructuralChange {
symbol: rm.old.name.clone(),
qualified_name: rm.old.qualified_name.clone(),
kind: rm.old.kind,
package: rm.old.package.clone(),
change_type: StructuralChangeType::Renamed {
from: ChangeSubject::Symbol { kind: rm.old.kind },
to: ChangeSubject::Symbol { kind: rm.new.kind },
},
before: Some(rm.old.name.clone()),
after: Some(rm.new.name.clone()),
description: format!(
"Exported {} `{}` was renamed to `{}`",
kind_label(rm.old.kind),
rm.old.name,
rm.new.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
{
let token_remaining_removed: Vec<&Symbol<M>> = removed
.iter()
.filter(|s| {
!relocated_old.contains(s.qualified_name.as_str())
&& !renamed_old.contains(s.qualified_name.as_str())
})
.copied()
.collect();
let token_remaining_added: Vec<&Symbol<M>> = added
.iter()
.filter(|s| {
!relocated_new.contains(s.qualified_name.as_str())
&& !renamed_new.contains(s.qualified_name.as_str())
})
.copied()
.collect();
let token_renames =
rename::detect_token_renames(&token_remaining_removed, &token_remaining_added, |sym| {
semantics.extract_rename_fallback_key(sym)
});
for rm in &token_renames {
renamed_old.insert(rm.old.qualified_name.as_str());
renamed_new.insert(rm.new.qualified_name.as_str());
changes.push(StructuralChange {
symbol: rm.old.name.clone(),
qualified_name: rm.old.qualified_name.clone(),
kind: rm.old.kind,
package: rm.old.package.clone(),
change_type: StructuralChangeType::Renamed {
from: ChangeSubject::Symbol { kind: rm.old.kind },
to: ChangeSubject::Symbol { kind: rm.new.kind },
},
before: Some(symbol_summary(rm.old)),
after: Some(symbol_summary(rm.new)),
description: format!(
"Exported {} `{}` was renamed to `{}`",
kind_label(rm.old.kind),
rm.old.name,
rm.new.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
}
for sym in &removed {
if relocated_old.contains(sym.qualified_name.as_str())
|| renamed_old.contains(sym.qualified_name.as_str())
{
continue;
}
changes.push(StructuralChange {
symbol: sym.name.clone(),
qualified_name: sym.qualified_name.clone(),
kind: sym.kind,
package: sym.package.clone(),
change_type: StructuralChangeType::Removed(ChangeSubject::Symbol { kind: sym.kind }),
before: Some(symbol_summary(sym)),
after: None,
description: format!(
"Exported {} `{}` was removed",
kind_label(sym.kind),
sym.name
),
is_breaking: true,
impact: None,
migration_target: None,
});
}
for sym in &added {
if relocated_new.contains(sym.qualified_name.as_str())
|| renamed_new.contains(sym.qualified_name.as_str())
{
continue;
}
changes.push(StructuralChange {
symbol: sym.name.clone(),
qualified_name: sym.qualified_name.clone(),
kind: sym.kind,
package: sym.package.clone(),
change_type: StructuralChangeType::Added(ChangeSubject::Symbol { kind: sym.kind }),
before: None,
after: Some(symbol_summary(sym)),
description: format!("Exported {} `{}` was added", kind_label(sym.kind), sym.name),
is_breaking: false,
impact: None,
migration_target: None,
});
}
for sym_old in &old_symbols {
if let Some(sym_new) = new_map.get(sym_old.qualified_name.as_str()) {
diff_symbol(sym_old, sym_new, &mut changes, semantics);
}
}
{
let final_removed: Vec<&Symbol<M>> = removed
.iter()
.filter(|s| {
!relocated_old.contains(s.qualified_name.as_str())
&& !renamed_old.contains(s.qualified_name.as_str())
})
.copied()
.collect();
let migrations = detect_migrations(
&final_removed,
&old_symbols,
&new_symbols,
semantics,
&dir_renames,
);
for mig in &migrations {
for change in changes.iter_mut() {
if change.qualified_name == mig.removed.qualified_name
&& matches!(
change.change_type,
StructuralChangeType::Removed(ChangeSubject::Symbol { .. })
)
{
change.migration_target = Some(mig.target.clone());
let mut matching_names: Vec<&str> = mig
.target
.matching_members
.iter()
.map(|m| m.old_name.as_str())
.collect();
matching_names.sort();
let mut removed_names: Vec<&str> = mig
.target
.removed_only_members
.iter()
.map(|s| s.as_str())
.collect();
removed_names.sort();
let base = change.description.trim_end_matches(" was removed");
let import_hint = if mig.target.removed_symbol == mig.target.replacement_symbol
&& mig.target.removed_qualified_name
!= mig.target.replacement_qualified_name
{
let old_import = semantics.derive_import_subpath(
mig.target.removed_package.as_deref(),
&mig.target.removed_qualified_name,
);
let new_import = semantics.derive_import_subpath(
mig.target.replacement_package.as_deref(),
&mig.target.replacement_qualified_name,
);
if old_import != new_import {
format!(
"\n Import change: {}",
semantics.format_import_change(
&mig.target.removed_symbol,
&old_import,
&new_import,
),
)
} else {
String::new()
}
} else {
String::new()
};
let label = semantics.member_label();
let mut desc = format!(
"{} was removed — migrate to `{}`.{}\n Matching {} (use on `{}` instead): {}",
base,
mig.target.replacement_symbol,
import_hint,
label,
mig.target.replacement_symbol,
matching_names.join(", "),
);
if !removed_names.is_empty() {
desc.push_str(&format!(
"\n Removed {} with no direct equivalent: {}\
\n NOTE: Only address removed {} that your code actually uses. \
If not referenced in the file, ignore it.",
label,
removed_names.join(", "),
label,
));
}
if mig.target.old_extends.is_some() || mig.target.new_extends.is_some() {
let old_ext = mig.target.old_extends.as_deref().unwrap_or("(none)");
let new_ext = mig.target.new_extends.as_deref().unwrap_or("(none)");
desc.push_str(&format!(
"\n Base type changed: {} → {}. \
Inherited members from the old base type are no longer available.",
old_ext, new_ext,
));
}
change.description = desc;
break;
}
}
}
}
semantics.post_process(&mut changes);
changes
}
pub fn diff_surfaces<M: Default + Clone + PartialEq>(
old: &ApiSurface<M>,
new: &ApiSurface<M>,
) -> Vec<StructuralChange> {
diff_surfaces_with_semantics(old, new, &MinimalSemantics)
}
pub(crate) struct MinimalSemantics;
impl<M: Default + Clone + PartialEq> LanguageSemantics<M> for MinimalSemantics {
fn is_member_addition_breaking(&self, _container: &Symbol<M>, _member: &Symbol<M>) -> bool {
false
}
fn same_family(&self, a: &Symbol<M>, b: &Symbol<M>) -> bool {
let a_dir = a
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let b_dir = b
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
a_dir == b_dir
}
fn same_identity(&self, a: &Symbol<M>, b: &Symbol<M>) -> bool {
a.name == b.name
}
fn visibility_rank(&self, v: crate::types::Visibility) -> u8 {
helpers::visibility_rank(v)
}
}
fn validate_renames<'a, M, S>(
renames: &'a [rename::RenameMatch<'a, M>],
semantics: &S,
) -> (
Vec<&'a rename::RenameMatch<'a, M>>,
Vec<&'a rename::RenameMatch<'a, M>>,
)
where
M: Default + Clone + PartialEq,
S: LanguageSemantics<M>,
{
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for rm in renames {
let old_rt = rm
.old
.signature
.as_ref()
.and_then(|s| s.return_type.as_deref());
let new_rt = rm
.new
.signature
.as_ref()
.and_then(|s| s.return_type.as_deref());
let types_match = match (old_rt, new_rt) {
(Some(o), Some(n)) => {
compare::types_structurally_similar(o, n, semantics.primitive_type_names())
}
_ => true, };
if !types_match {
tracing::info!(
old = %rm.old.name,
new = %rm.new.name,
old_type = old_rt.unwrap_or("?"),
new_type = new_rt.unwrap_or("?"),
"Rename rejected: type-incompatible"
);
rejected.push(rm);
continue;
}
if rm.old.name != rm.new.name && !semantics.same_family(rm.old, rm.new) {
let old_dir = rm
.old
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_dir = rm
.new
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let has_sibling = renames.iter().any(|other| {
if other.old.qualified_name == rm.old.qualified_name {
return false;
}
let other_old_dir = other
.old
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let other_new_dir = other
.new
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
old_dir == other_old_dir && new_dir == other_new_dir
});
if !has_sibling {
tracing::info!(
old = %rm.old.name,
new = %rm.new.name,
old_dir = %old_dir,
new_dir = %new_dir,
"Rename rejected: cross-family type_alias/enum with no sibling rename"
);
rejected.push(rm);
continue;
}
}
accepted.push(rm);
}
(accepted, rejected)
}