use crate::types::{MemberMapping, MigrationTarget, Symbol, SymbolKind};
use std::collections::{HashMap, HashSet};
const MIN_OVERLAP_RATIO: f64 = 0.25;
fn min_overlap_count(member_count: usize) -> usize {
match member_count {
0 => 1,
1..=3 => 1,
4..=6 => 2,
_ => 3,
}
}
pub(super) struct MigrationMatch<'a> {
pub removed: &'a Symbol,
pub target: MigrationTarget,
}
pub(super) fn detect_migrations<'a>(
removed: &[&'a Symbol],
old_symbols: &[&'a Symbol],
new_symbols: &[&'a Symbol],
semantics: &dyn crate::traits::LanguageSemantics,
) -> Vec<MigrationMatch<'a>> {
let removed_interfaces: Vec<&&Symbol> = removed
.iter()
.filter(|s| is_container_kind(s.kind))
.collect();
if removed_interfaces.is_empty() {
return Vec::new();
}
let new_containers: Vec<&Symbol> = new_symbols
.iter()
.filter(|s| is_container_kind(s.kind))
.copied()
.collect();
let old_by_qname: HashMap<&str, &Symbol> = old_symbols
.iter()
.filter(|s| is_container_kind(s.kind))
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let mut results = Vec::new();
let mut matched_removed: HashSet<&str> = HashSet::new();
for removed_sym in &removed_interfaces {
let removed_members: HashSet<&str> = removed_sym
.members
.iter()
.map(|m| m.name.as_str())
.collect();
if removed_members.is_empty() {
continue;
}
let candidates: Vec<&&Symbol> = new_containers
.iter()
.filter(|c| semantics.same_family(removed_sym, c))
.collect();
let mut best_match: Option<(&Symbol, f64, Vec<MemberMapping>, Vec<String>)> = None;
for candidate in candidates {
if candidate.qualified_name == removed_sym.qualified_name {
continue;
}
let candidate_members: HashSet<&str> =
if let Some(old_candidate) = old_by_qname.get(candidate.qualified_name.as_str()) {
let old_members: HashSet<&str> = old_candidate
.members
.iter()
.map(|m| m.name.as_str())
.collect();
candidate
.members
.iter()
.map(|m| m.name.as_str())
.filter(|name| !old_members.contains(name))
.collect()
} else {
candidate.members.iter().map(|m| m.name.as_str()).collect()
};
let is_same_identity = semantics.same_identity(removed_sym, candidate);
let effective_candidate_members: HashSet<&str> = if is_same_identity {
candidate.members.iter().map(|m| m.name.as_str()).collect()
} else {
candidate_members
};
if effective_candidate_members.is_empty() {
continue;
}
let matching: Vec<MemberMapping> = removed_members
.iter()
.filter(|m| effective_candidate_members.contains(*m))
.map(|m| MemberMapping {
old_name: m.to_string(),
new_name: m.to_string(),
})
.collect();
if matching.len() < min_overlap_count(removed_members.len()) {
continue;
}
let ratio_removed = matching.len() as f64 / removed_members.len() as f64;
let ratio_new = matching.len() as f64 / effective_candidate_members.len() as f64;
let best_ratio = ratio_removed.max(ratio_new);
if best_ratio < MIN_OVERLAP_RATIO {
continue;
}
let removed_only: Vec<String> = removed_members
.iter()
.filter(|m| !effective_candidate_members.contains(*m))
.map(|m| m.to_string())
.collect();
if best_match
.as_ref()
.is_none_or(|(_, r, _, _)| best_ratio > *r)
{
best_match = Some((*candidate, best_ratio, matching, removed_only));
}
}
if let Some((replacement, ratio, matching_members, removed_only_members)) = best_match {
if !matched_removed.contains(removed_sym.qualified_name.as_str()) {
matched_removed.insert(&removed_sym.qualified_name);
results.push(MigrationMatch {
removed: removed_sym,
target: MigrationTarget {
removed_symbol: removed_sym.name.clone(),
removed_qualified_name: removed_sym.qualified_name.clone(),
removed_package: removed_sym.package.clone(),
replacement_symbol: replacement.name.clone(),
replacement_qualified_name: replacement.qualified_name.clone(),
replacement_package: replacement.package.clone(),
matching_members,
removed_only_members,
overlap_ratio: ratio,
},
});
}
}
}
results
}
fn is_container_kind(kind: SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Interface | SymbolKind::Class | SymbolKind::Enum
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::MinimalSemantics;
use crate::types::{Symbol, SymbolKind, Visibility};
fn make_interface(name: &str, file: &str, members: &[&str]) -> Symbol {
let mut sym = Symbol::new(
name,
&format!("{}.{}", file, name),
SymbolKind::Interface,
Visibility::Exported,
file,
1,
);
for member_name in members {
sym.members.push(Symbol::new(
*member_name,
&format!("{}.{}.{}", file, name, member_name),
SymbolKind::Property,
Visibility::Public,
file,
1,
));
}
sym
}
#[test]
fn test_merge_child_into_parent_emptystate_pattern() {
let old_header = make_interface(
"EmptyStateHeaderProps",
"components/EmptyState/EmptyStateHeader.d.ts",
&[
"children",
"className",
"headingLevel",
"icon",
"titleClassName",
"titleText",
],
);
let old_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&["children", "className", "variant"],
);
let new_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&[
"children",
"className",
"variant",
"titleText",
"headingLevel",
"icon",
"status",
"titleClassName",
"headerClassName",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_header, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_header];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(results.len(), 1);
let m = &results[0];
assert_eq!(m.target.removed_symbol, "EmptyStateHeaderProps");
assert_eq!(m.target.replacement_symbol, "EmptyStateProps");
assert!(
m.target.overlap_ratio > 0.5,
"Expected >50% overlap, got {}",
m.target.overlap_ratio
);
assert!(
m.target.matching_members.len() >= 4,
"Expected >= 4 matching members, got {}: {:?}",
m.target.matching_members.len(),
m.target
.matching_members
.iter()
.map(|m| &m.old_name)
.collect::<Vec<_>>()
);
}
#[test]
fn test_same_name_replacement_select_pattern() {
let old_select = make_interface(
"SelectProps",
"deprecated/components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"isDisabled",
"isGrouped",
"isCreatable",
"selections",
"onToggle",
"direction",
"position",
"toggleRef",
"width",
"zIndex",
"variant",
],
);
let old_main_select = make_interface(
"SelectProps",
"components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"direction",
"position",
"selected",
"toggle",
"toggleRef",
"width",
"zIndex",
"onOpenChange",
"innerRef",
"isScrollable",
],
);
let new_main_select = make_interface(
"SelectProps",
"components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"direction",
"position",
"selected",
"toggle",
"toggleRef",
"width",
"zIndex",
"onOpenChange",
"innerRef",
"isScrollable",
"variant",
"focusTimeoutDelay",
"onToggleKeydown",
"shouldPreventScrollOnItemFocus",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_select, &old_main_select];
let new_symbols: Vec<&Symbol> = vec![&new_main_select];
let removed: Vec<&Symbol> = vec![&old_select];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(results.len(), 0);
}
#[test]
fn test_decompose_into_children_modal_pattern() {
let old_modal = make_interface(
"ModalProps",
"components/Modal/Modal.d.ts",
&[
"children",
"className",
"isOpen",
"variant",
"title",
"actions",
"description",
"footer",
"header",
"help",
"titleIconVariant",
"onClose",
"showClose",
],
);
let new_modal = make_interface(
"ModalProps",
"components/Modal/Modal.d.ts",
&["children", "className", "isOpen", "variant", "onClose"],
);
let new_header = make_interface(
"ModalHeaderProps",
"components/Modal/ModalHeader.d.ts",
&[
"children",
"className",
"title",
"description",
"help",
"titleIconVariant",
"labelId",
"descriptorId",
"titleScreenReaderText",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_modal];
let new_symbols: Vec<&Symbol> = vec![&new_modal, &new_header];
let removed: Vec<&Symbol> = vec![];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(
results.len(),
0,
"No removed interfaces => no migration suggestions"
);
}
#[test]
fn test_no_false_positive_unrelated_interfaces() {
let removed_foo = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className", "onClick"],
);
let new_bar = make_interface(
"BarProps",
"components/Bar/Bar.d.ts",
&["children", "className", "onSubmit"],
);
let old_symbols: Vec<&Symbol> = vec![&removed_foo];
let new_symbols: Vec<&Symbol> = vec![&new_bar];
let removed: Vec<&Symbol> = vec![&removed_foo];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(results.len(), 0, "Different directories should not match");
}
#[test]
fn test_small_overlap_with_adaptive_threshold() {
let removed_header = make_interface(
"FooHeaderProps",
"components/Foo/FooHeader.d.ts",
&["title", "subtitle"],
);
let new_foo = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&[
"children",
"className",
"title",
"variant",
"size",
"isOpen",
"onClose",
"isFullscreen",
],
);
let old_symbols: Vec<&Symbol> = vec![&removed_header];
let new_symbols: Vec<&Symbol> = vec![&new_foo];
let removed: Vec<&Symbol> = vec![&removed_header];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(
results.len(),
1,
"Small interface with 50% match should be detected as absorption"
);
assert_eq!(results[0].target.removed_symbol, "FooHeaderProps");
assert_eq!(results[0].target.replacement_symbol, "FooProps");
}
#[test]
fn test_small_interface_absorption_emptystate_icon() {
let old_icon_props = make_interface(
"EmptyStateIconProps",
"components/EmptyState/EmptyStateIcon.d.ts",
&["icon", "className"],
);
let old_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&["children", "className", "variant"],
);
let new_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&[
"children",
"className",
"variant",
"icon", "titleText",
"headingLevel",
"status",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_icon_props, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_icon_props];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert_eq!(
results.len(),
1,
"Should detect EmptyStateIconProps → EmptyStateProps absorption. Got {} results.",
results.len()
);
let m = &results[0];
assert_eq!(m.target.removed_symbol, "EmptyStateIconProps");
assert_eq!(m.target.replacement_symbol, "EmptyStateProps");
let matched_names: Vec<&str> = m
.target
.matching_members
.iter()
.map(|mm| mm.old_name.as_str())
.collect();
assert!(
matched_names.contains(&"icon"),
"Should match 'icon'. Matched: {:?}",
matched_names
);
assert!(
m.target.overlap_ratio >= 0.25,
"Overlap ratio should be >= 25%, got {}",
m.target.overlap_ratio
);
}
#[test]
fn test_adaptive_threshold_values() {
assert_eq!(min_overlap_count(0), 1);
assert_eq!(min_overlap_count(1), 1);
assert_eq!(min_overlap_count(2), 1);
assert_eq!(min_overlap_count(3), 1);
assert_eq!(min_overlap_count(4), 2);
assert_eq!(min_overlap_count(5), 2);
assert_eq!(min_overlap_count(6), 2);
assert_eq!(min_overlap_count(7), 3);
assert_eq!(min_overlap_count(10), 3);
assert_eq!(min_overlap_count(50), 3);
}
#[test]
fn test_single_member_interface_no_false_positive() {
let old_tiny = make_interface("TinyProps", "components/Foo/Tiny.d.ts", &["uniqueProp"]);
let old_parent = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className"],
);
let new_parent = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className", "newProp"], );
let old_symbols: Vec<&Symbol> = vec![&old_tiny, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_tiny];
let results = detect_migrations(&removed, &old_symbols, &new_symbols, &MinimalSemantics);
assert!(
results.is_empty(),
"Should NOT produce migration for non-matching single-member interface"
);
}
}