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 remaining_removed: Vec<&Symbol> = removed
.iter()
.filter(|s| !relocated_old.contains(s.qualified_name.as_str()))
.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 renamed_old: HashSet<&str> = renames
.iter()
.map(|r| r.old.qualified_name.as_str())
.collect();
let renamed_new: HashSet<&str> = renames
.iter()
.map(|r| r.new.qualified_name.as_str())
.collect();
for rm in &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,
});
}
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);
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 = mig
.target
.removed_package
.as_deref()
.unwrap_or(&mig.target.removed_qualified_name);
let new_import = mig
.target
.replacement_package
.as_deref()
.unwrap_or(&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: {}",
removed_names.join(", "),
));
}
change.description = desc;
break;
}
}
}
}
semantics.post_process(&mut changes);
changes
}
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)
}
}