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::{is_star_reexport, kind_label, symbol_summary};
use migration::detect_migrations;
use relocate::{detect_relocations, RelocationType};
use rename::detect_renames;
pub fn diff_surfaces_with_semantics(
old: &ApiSurface,
new: &ApiSurface,
semantics: &dyn LanguageSemantics,
) -> Vec<StructuralChange> {
let mut changes = Vec::new();
let old_symbols: Vec<&Symbol> = old
.symbols
.iter()
.filter(|s| !is_star_reexport(s))
.collect();
let new_symbols: Vec<&Symbol> = new
.symbols
.iter()
.filter(|s| !is_star_reexport(s))
.collect();
let old_map: HashMap<&str, &Symbol> = old_symbols
.iter()
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let new_map: HashMap<&str, &Symbol> = new_symbols
.iter()
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let removed: Vec<&Symbol> = old_symbols
.iter()
.filter(|s| !new_map.contains_key(s.qualified_name.as_str()))
.copied()
.collect();
let added: Vec<&Symbol> = 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);
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>> = {
let mut map: HashMap<&str, Vec<&Symbol>> = HashMap::new();
for s in new_symbols.iter() {
map.entry(s.name.as_str()).or_default().push(s);
}
map
};
let remaining_removed: Vec<&Symbol> = 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| p.trim_end_matches("/deprecated").trim_end_matches("/next"));
!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| p.trim_end_matches("/deprecated").trim_end_matches("/next"));
old_base == new_base
})
})
.copied()
.collect();
let remaining_added: Vec<&Symbol> = added
.iter()
.filter(|s| !relocated_new.contains(s.qualified_name.as_str()))
.copied()
.collect();
let renames = detect_renames(&remaining_removed, &remaining_added);
let mut compatible_renames = Vec::new();
let mut incompatible_renames = 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),
_ => true, };
if types_match {
compatible_renames.push(rm);
} else {
tracing::info!(
old = %rm.old.name,
new = %rm.new.name,
old_type = old_rt.unwrap_or("?"),
new_type = new_rt.unwrap_or("?"),
"Type-incompatible rename — emitting as Removed + Added instead of Renamed"
);
incompatible_renames.push(rm);
}
}
let mut renamed_old: HashSet<&str> = compatible_renames
.iter()
.map(|r| r.old.qualified_name.as_str())
.collect();
let mut renamed_new: HashSet<&str> = compatible_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 &compatible_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> = 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> = 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);
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> = 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 = derive_import_subpath(
mig.target.removed_package.as_deref(),
&mig.target.removed_qualified_name,
);
let new_import = 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 mut desc = format!(
"{} was removed — migrate to `{}`.{}\n Matching props (use on `{}` instead): {}",
base,
mig.target.replacement_symbol,
import_hint,
mig.target.replacement_symbol,
matching_names.join(", "),
);
if !removed_names.is_empty() {
desc.push_str(&format!(
"\n Removed props with no direct equivalent: {}\
\n NOTE: Only address removed props that your code actually uses. \
If a removed prop is not referenced in the file, ignore it.",
removed_names.join(", "),
));
}
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
}
fn derive_import_subpath(package: Option<&str>, qualified_name: &str) -> String {
let base = package.unwrap_or("unknown");
if qualified_name.contains("/deprecated/") {
format!("{}/deprecated", base)
} else if qualified_name.contains("/next/") {
format!("{}/next", base)
} else {
base.to_string()
}
}
pub fn diff_surfaces(old: &ApiSurface, new: &ApiSurface) -> Vec<StructuralChange> {
diff_surfaces_with_semantics(old, new, &MinimalSemantics)
}
pub(crate) struct MinimalSemantics;
impl LanguageSemantics for MinimalSemantics {
fn is_member_addition_breaking(&self, _container: &Symbol, _member: &Symbol) -> bool {
false
}
fn same_family(&self, a: &Symbol, b: &Symbol) -> 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, b: &Symbol) -> bool {
a.name == b.name
}
fn visibility_rank(&self, v: crate::types::Visibility) -> u8 {
helpers::visibility_rank(v)
}
}