use std::collections::HashMap;
use std::fmt::{self, Display};
use crate::semiring::Semiring;
#[cfg(test)]
use crate::wfst::Wfst;
use crate::wfst::{MutableWfst, VectorWfst, WeightedTransition};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub const fn new(major: u32, minor: u32) -> Self {
Self {
major,
minor,
patch: 0,
}
}
pub const fn with_patch(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<_> = s.split('.').collect();
if parts.is_empty() || parts.len() > 3 {
return None;
}
let major = parts[0].parse().ok()?;
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
Some(Self {
major,
minor,
patch,
})
}
pub fn satisfies(&self, from: Version, to: Version) -> bool {
*self >= from && *self <= to
}
}
impl Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VersionRange {
pub from: Version,
pub to: Version,
}
impl VersionRange {
pub const fn new(from: Version, to: Version) -> Self {
Self { from, to }
}
pub fn contains(&self, version: Version) -> bool {
version >= self.from && version <= self.to
}
}
impl Display for VersionRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} - {}", self.from, self.to)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MigrationType {
RenameFunction {
old_name: String,
new_name: String,
},
RenameParameter {
function: Option<String>,
old_name: String,
new_name: String,
},
RenameType {
old_name: String,
new_name: String,
},
ChangeSignature {
function: String,
old_params: Vec<String>,
new_params: Vec<String>,
},
ReplaceCall {
old_pattern: Vec<String>,
new_pattern: Vec<String>,
},
RemoveFunction {
function: String,
message: String,
},
AddParameter {
function: String,
param_name: String,
default_value: String,
},
RemoveParameter {
function: String,
param_name: String,
},
Custom {
description: String,
old_tokens: Vec<String>,
new_tokens: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub struct ApiMigrationRule {
pub id: String,
pub description: String,
pub migration_type: MigrationType,
pub version_range: VersionRange,
pub cost: f64,
pub automatic: bool,
}
impl ApiMigrationRule {
pub fn rename_function(
old_name: impl Into<String>,
new_name: impl Into<String>,
from: Version,
to: Version,
) -> Self {
let old_name = old_name.into();
let new_name = new_name.into();
Self {
id: format!("rename_fn_{}_{}", &old_name, &new_name),
description: format!("Rename function {} to {}", &old_name, &new_name),
migration_type: MigrationType::RenameFunction { old_name, new_name },
version_range: VersionRange::new(from, to),
cost: 0.1,
automatic: true,
}
}
pub fn rename_type(
old_name: impl Into<String>,
new_name: impl Into<String>,
from: Version,
to: Version,
) -> Self {
let old_name = old_name.into();
let new_name = new_name.into();
Self {
id: format!("rename_type_{}_{}", &old_name, &new_name),
description: format!("Rename type {} to {}", &old_name, &new_name),
migration_type: MigrationType::RenameType { old_name, new_name },
version_range: VersionRange::new(from, to),
cost: 0.1,
automatic: true,
}
}
pub fn rename_parameter(
function: Option<impl Into<String>>,
old_name: impl Into<String>,
new_name: impl Into<String>,
from: Version,
to: Version,
) -> Self {
let function = function.map(Into::into);
let old_name = old_name.into();
let new_name = new_name.into();
let fn_part = function.as_deref().unwrap_or("*");
Self {
id: format!("rename_param_{}_{}_{}", fn_part, &old_name, &new_name),
description: format!(
"Rename parameter {} to {} in {}",
&old_name, &new_name, fn_part
),
migration_type: MigrationType::RenameParameter {
function,
old_name,
new_name,
},
version_range: VersionRange::new(from, to),
cost: 0.1,
automatic: true,
}
}
pub fn replace(
old_tokens: impl IntoIterator<Item = impl Into<String>>,
new_tokens: impl IntoIterator<Item = impl Into<String>>,
from: Version,
to: Version,
) -> Self {
let old_tokens: Vec<_> = old_tokens.into_iter().map(Into::into).collect();
let new_tokens: Vec<_> = new_tokens.into_iter().map(Into::into).collect();
let id = format!("replace_{}", old_tokens.join("_"));
Self {
id,
description: format!(
"Replace {} with {}",
old_tokens.join(" "),
new_tokens.join(" ")
),
migration_type: MigrationType::ReplaceCall {
old_pattern: old_tokens,
new_pattern: new_tokens,
},
version_range: VersionRange::new(from, to),
cost: 0.2,
automatic: true,
}
}
pub fn deprecate(
function: impl Into<String>,
message: impl Into<String>,
from: Version,
to: Version,
) -> Self {
let function = function.into();
Self {
id: format!("deprecate_{}", &function),
description: format!("Mark {} as deprecated", &function),
migration_type: MigrationType::RemoveFunction {
function,
message: message.into(),
},
version_range: VersionRange::new(from, to),
cost: 1.0, automatic: false,
}
}
pub fn with_cost(mut self, cost: f64) -> Self {
self.cost = cost;
self
}
pub fn manual_review(mut self) -> Self {
self.automatic = false;
self
}
pub fn old_tokens(&self) -> Vec<&str> {
match &self.migration_type {
MigrationType::RenameFunction { old_name, .. } => vec![old_name.as_str()],
MigrationType::RenameParameter { old_name, .. } => vec![old_name.as_str()],
MigrationType::RenameType { old_name, .. } => vec![old_name.as_str()],
MigrationType::ReplaceCall { old_pattern, .. } => {
old_pattern.iter().map(|s| s.as_str()).collect()
}
MigrationType::RemoveFunction { function, .. } => vec![function.as_str()],
MigrationType::ChangeSignature { function, .. } => vec![function.as_str()],
MigrationType::AddParameter { function, .. } => vec![function.as_str()],
MigrationType::RemoveParameter { function, .. } => vec![function.as_str()],
MigrationType::Custom { old_tokens, .. } => {
old_tokens.iter().map(|s| s.as_str()).collect()
}
}
}
pub fn new_tokens(&self) -> Vec<&str> {
match &self.migration_type {
MigrationType::RenameFunction { new_name, .. } => vec![new_name.as_str()],
MigrationType::RenameParameter { new_name, .. } => vec![new_name.as_str()],
MigrationType::RenameType { new_name, .. } => vec![new_name.as_str()],
MigrationType::ReplaceCall { new_pattern, .. } => {
new_pattern.iter().map(|s| s.as_str()).collect()
}
MigrationType::RemoveFunction { message, .. } => vec![message.as_str()],
MigrationType::ChangeSignature { function, .. } => vec![function.as_str()],
MigrationType::AddParameter { function, .. } => vec![function.as_str()],
MigrationType::RemoveParameter { function, .. } => vec![function.as_str()],
MigrationType::Custom { new_tokens, .. } => {
new_tokens.iter().map(|s| s.as_str()).collect()
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct MigrationStats {
pub rules_applied: usize,
pub automatic_migrations: usize,
pub manual_review_items: usize,
pub total_cost: f64,
}
#[derive(Debug, Clone)]
pub struct MigrationResult {
pub original: Vec<String>,
pub migrated: Vec<String>,
pub applied_rules: Vec<String>,
pub stats: MigrationStats,
}
#[derive(Debug, Clone)]
pub struct ApiMigrationTransducer<W: Semiring> {
rules_by_token: HashMap<String, Vec<ApiMigrationRule>>,
all_rules: Vec<ApiMigrationRule>,
source_version: Version,
target_version: Version,
_phantom: std::marker::PhantomData<W>,
}
impl<W: Semiring> ApiMigrationTransducer<W> {
pub fn new(source_version: Version, target_version: Version) -> Self {
Self {
rules_by_token: HashMap::new(),
all_rules: Vec::new(),
source_version,
target_version,
_phantom: std::marker::PhantomData,
}
}
pub fn add_rule(&mut self, rule: ApiMigrationRule) {
if let Some(first_token) = rule.old_tokens().first() {
self.rules_by_token
.entry(first_token.to_string())
.or_default()
.push(rule.clone());
}
self.all_rules.push(rule);
}
pub fn applicable_rules(&self) -> impl Iterator<Item = &ApiMigrationRule> {
self.all_rules.iter().filter(|rule| {
rule.version_range.contains(self.source_version)
|| rule.version_range.contains(self.target_version)
})
}
pub fn migrate(&self, tokens: &[String]) -> MigrationResult {
let mut result = Vec::new();
let mut applied_rules = Vec::new();
let mut stats = MigrationStats::default();
let mut i = 0;
while i < tokens.len() {
let token = &tokens[i];
if let Some(rules) = self.rules_by_token.get(token) {
let mut matched = false;
for rule in rules {
if !rule.version_range.contains(self.source_version) {
continue;
}
let old_tokens = rule.old_tokens();
if tokens.len() >= i + old_tokens.len()
&& tokens[i..i + old_tokens.len()]
.iter()
.zip(old_tokens.iter())
.all(|(a, b)| a == *b)
{
let new_tokens = rule.new_tokens();
result.extend(new_tokens.iter().map(|s| s.to_string()));
i += old_tokens.len();
applied_rules.push(rule.id.clone());
stats.rules_applied += 1;
stats.total_cost += rule.cost;
if rule.automatic {
stats.automatic_migrations += 1;
} else {
stats.manual_review_items += 1;
}
matched = true;
break;
}
}
if !matched {
result.push(token.clone());
i += 1;
}
} else {
result.push(token.clone());
i += 1;
}
}
MigrationResult {
original: tokens.to_vec(),
migrated: result,
applied_rules,
stats,
}
}
pub fn build_wfst(&self, weight_fn: impl Fn(f64) -> W) -> VectorWfst<String, W> {
let mut fst = VectorWfst::new();
let start = fst.add_state();
fst.set_start(start);
fst.set_final(start, W::one());
for rule in &self.all_rules {
if !rule.version_range.contains(self.source_version) {
continue;
}
let old_tokens = rule.old_tokens();
let new_tokens = rule.new_tokens();
let weight = weight_fn(rule.cost);
if old_tokens.is_empty() {
continue;
}
let mut current = start;
for (idx, old_token) in old_tokens.iter().enumerate() {
let is_last = idx == old_tokens.len() - 1;
if is_last {
let output = new_tokens.join(" ");
fst.add_transition(WeightedTransition::new(
current,
Some(old_token.to_string()),
Some(output),
start,
weight.clone(),
));
} else {
let next = fst.add_state();
fst.add_transition(WeightedTransition::new(
current,
Some(old_token.to_string()),
None, next,
W::one(),
));
current = next;
}
}
}
fst
}
pub fn source_version(&self) -> Version {
self.source_version
}
pub fn target_version(&self) -> Version {
self.target_version
}
pub fn rules(&self) -> &[ApiMigrationRule] {
&self.all_rules
}
}
#[derive(Debug, Clone)]
pub struct ApiMigrationBuilder<W: Semiring> {
rules: Vec<ApiMigrationRule>,
source_version: Version,
target_version: Version,
_phantom: std::marker::PhantomData<W>,
}
impl<W: Semiring> ApiMigrationBuilder<W> {
pub fn new(source_version: Version, target_version: Version) -> Self {
Self {
rules: Vec::new(),
source_version,
target_version,
_phantom: std::marker::PhantomData,
}
}
pub fn add_rule(mut self, rule: ApiMigrationRule) -> Self {
self.rules.push(rule);
self
}
pub fn add_rules(mut self, rules: impl IntoIterator<Item = ApiMigrationRule>) -> Self {
self.rules.extend(rules);
self
}
pub fn build(self) -> ApiMigrationTransducer<W> {
let mut transducer = ApiMigrationTransducer::new(self.source_version, self.target_version);
for rule in self.rules {
transducer.add_rule(rule);
}
transducer
}
}
pub mod patterns {
use super::*;
pub fn react_class_to_function() -> Vec<ApiMigrationRule> {
let v16 = Version::new(16, 8);
let v18 = Version::new(18, 0);
vec![
ApiMigrationRule::replace(
["componentDidMount", "(", ")"],
["useEffect", "(", "(", ")", "=>", "{"],
v16,
v18,
),
ApiMigrationRule::replace(
["componentWillUnmount", "(", ")"],
[
"useEffect",
"(",
"(",
")",
"=>",
"{",
"return",
"(",
")",
"=>",
"{",
],
v16,
v18,
),
ApiMigrationRule::replace(["this", ".", "setState"], ["setState"], v16, v18),
ApiMigrationRule::replace(["this", ".", "state"], ["state"], v16, v18),
]
}
pub fn python2_to_python3() -> Vec<ApiMigrationRule> {
let v2 = Version::new(2, 7);
let v3 = Version::new(3, 0);
vec![
ApiMigrationRule::replace(["print", "\""], ["print", "(", "\""], v2, v3),
ApiMigrationRule::replace(["xrange"], ["range"], v2, v3),
ApiMigrationRule::replace(["raw_input"], ["input"], v2, v3),
ApiMigrationRule::replace(["unicode"], ["str"], v2, v3),
ApiMigrationRule::rename_function("iteritems", "items", v2, v3),
ApiMigrationRule::rename_function("iterkeys", "keys", v2, v3),
ApiMigrationRule::rename_function("itervalues", "values", v2, v3),
]
}
pub fn jquery_to_vanilla_js() -> Vec<ApiMigrationRule> {
let v1 = Version::new(1, 0);
let v4 = Version::new(4, 0);
vec![
ApiMigrationRule::replace(
["$", "(", "\"#"],
["document", ".", "getElementById", "(", "\""],
v1,
v4,
),
ApiMigrationRule::replace(
["$", "(", "\"."],
["document", ".", "querySelectorAll", "(", "\"."],
v1,
v4,
),
ApiMigrationRule::replace(
[".", "addClass", "("],
[".", "classList", ".", "add", "("],
v1,
v4,
),
ApiMigrationRule::replace(
[".", "removeClass", "("],
[".", "classList", ".", "remove", "("],
v1,
v4,
),
ApiMigrationRule::replace(
[".", "toggleClass", "("],
[".", "classList", ".", "toggle", "("],
v1,
v4,
),
ApiMigrationRule::replace([".", "attr", "("], [".", "getAttribute", "("], v1, v4),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::semiring::TropicalWeight;
#[test]
fn test_version_parsing() {
let v = Version::parse("1.2.3")
.expect("programming/api_migration.rs: required value was None/Err");
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
let v = Version::parse("2.0")
.expect("programming/api_migration.rs: required value was None/Err");
assert_eq!(v.major, 2);
assert_eq!(v.minor, 0);
assert_eq!(v.patch, 0);
let v =
Version::parse("3").expect("programming/api_migration.rs: required value was None/Err");
assert_eq!(v.major, 3);
assert_eq!(v.minor, 0);
assert_eq!(v.patch, 0);
}
#[test]
fn test_version_comparison() {
let v1 = Version::new(1, 0);
let v2 = Version::new(2, 0);
let v1_5 = Version::with_patch(1, 5, 0);
assert!(v1 < v2);
assert!(v1 < v1_5);
assert!(v1_5 < v2);
}
#[test]
fn test_version_range() {
let range = VersionRange::new(Version::new(1, 0), Version::new(2, 0));
assert!(range.contains(Version::new(1, 0)));
assert!(range.contains(Version::new(1, 5)));
assert!(range.contains(Version::new(2, 0)));
assert!(!range.contains(Version::new(0, 9)));
assert!(!range.contains(Version::new(2, 1)));
}
#[test]
fn test_rename_function_rule() {
let rule = ApiMigrationRule::rename_function(
"old_fn",
"new_fn",
Version::new(1, 0),
Version::new(2, 0),
);
assert_eq!(rule.old_tokens(), vec!["old_fn"]);
assert_eq!(rule.new_tokens(), vec!["new_fn"]);
assert!(rule.automatic);
}
#[test]
fn test_basic_migration() {
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
"old_fn",
"new_fn",
Version::new(1, 0),
Version::new(2, 0),
));
let tokens = vec![
"call".to_string(),
"old_fn".to_string(),
"(".to_string(),
")".to_string(),
];
let result = transducer.migrate(&tokens);
assert_eq!(result.migrated, vec!["call", "new_fn", "(", ")"]);
assert_eq!(result.stats.rules_applied, 1);
assert_eq!(result.stats.automatic_migrations, 1);
}
#[test]
fn test_multi_token_migration() {
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::replace(
["old", "method"],
["new_method"],
Version::new(1, 0),
Version::new(2, 0),
));
let tokens = vec!["obj".to_string(), "old".to_string(), "method".to_string()];
let result = transducer.migrate(&tokens);
assert_eq!(result.migrated, vec!["obj", "new_method"]);
assert_eq!(result.stats.rules_applied, 1);
}
#[test]
fn test_version_filtering() {
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
"v1_fn",
"v1_5_fn",
Version::new(1, 0),
Version::with_patch(1, 5, 0),
));
let tokens = vec!["v1_fn".to_string()];
let result = transducer.migrate(&tokens);
assert_eq!(result.migrated, vec!["v1_5_fn"]);
}
#[test]
fn test_no_match() {
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
"foo",
"bar",
Version::new(1, 0),
Version::new(2, 0),
));
let tokens = vec!["baz".to_string(), "qux".to_string()];
let result = transducer.migrate(&tokens);
assert_eq!(result.migrated, vec!["baz", "qux"]);
assert_eq!(result.stats.rules_applied, 0);
}
#[test]
fn test_builder() {
let transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationBuilder::new(Version::new(1, 0), Version::new(2, 0))
.add_rule(ApiMigrationRule::rename_function(
"a",
"b",
Version::new(1, 0),
Version::new(2, 0),
))
.add_rule(ApiMigrationRule::rename_type(
"OldType",
"NewType",
Version::new(1, 0),
Version::new(2, 0),
))
.build();
assert_eq!(transducer.rules().len(), 2);
}
#[test]
fn test_python_migration_rules() {
let rules = patterns::python2_to_python3();
assert!(!rules.is_empty());
let xrange_rule = rules.iter().find(|r| {
matches!(&r.migration_type, MigrationType::ReplaceCall { old_pattern, .. }
if old_pattern == &["xrange"])
});
assert!(xrange_rule.is_some());
}
#[test]
fn test_build_wfst() {
let transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationBuilder::new(Version::new(1, 0), Version::new(2, 0))
.add_rule(ApiMigrationRule::rename_function(
"old",
"new",
Version::new(1, 0),
Version::new(2, 0),
))
.build();
let fst = transducer.build_wfst(TropicalWeight::new);
assert_ne!(fst.start(), crate::wfst::NO_STATE);
}
#[test]
fn test_deprecation_rule() {
let rule = ApiMigrationRule::deprecate(
"deprecated_fn",
"Use new_fn instead",
Version::new(1, 0),
Version::new(2, 0),
);
assert!(!rule.automatic);
assert!(rule.cost > 0.5);
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(rule);
let tokens = vec!["deprecated_fn".to_string()];
let result = transducer.migrate(&tokens);
assert_eq!(result.stats.manual_review_items, 1);
assert_eq!(result.stats.automatic_migrations, 0);
}
#[test]
fn test_multiple_rules_in_sequence() {
let mut transducer: ApiMigrationTransducer<TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
"foo",
"bar",
Version::new(1, 0),
Version::new(2, 0),
));
transducer.add_rule(ApiMigrationRule::rename_function(
"baz",
"qux",
Version::new(1, 0),
Version::new(2, 0),
));
let tokens = vec![
"foo".to_string(),
"(".to_string(),
")".to_string(),
";".to_string(),
"baz".to_string(),
"(".to_string(),
")".to_string(),
];
let result = transducer.migrate(&tokens);
assert_eq!(result.migrated, vec!["bar", "(", ")", ";", "qux", "(", ")"]);
assert_eq!(result.stats.rules_applied, 2);
}
#[test]
fn test_version_display() {
let v = Version::with_patch(1, 2, 3);
assert_eq!(format!("{}", v), "1.2.3");
}
#[test]
fn test_version_range_display() {
let range = VersionRange::new(Version::new(1, 0), Version::new(2, 0));
assert_eq!(format!("{}", range), "1.0.0 - 2.0.0");
}
#[test]
fn test_rule_with_cost() {
let rule =
ApiMigrationRule::rename_function("a", "b", Version::new(1, 0), Version::new(2, 0))
.with_cost(0.5);
assert_eq!(rule.cost, 0.5);
}
mod property_tests {
use super::*;
use proptest::prelude::*;
fn arb_ident() -> impl Strategy<Value = String> {
proptest::collection::vec(
proptest::char::range('a', 'z').prop_map(|c| c as char),
1..=8,
)
.prop_map(|v| v.into_iter().collect())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn migration_is_idempotent(
old in arb_ident(),
new in arb_ident(),
) {
prop_assume!(old != new);
let mut transducer: ApiMigrationTransducer<crate::semiring::TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
&old,
&new,
Version::new(1, 0),
Version::new(2, 0),
));
let tokens = vec![old.clone(), "(".to_string(), ")".to_string()];
let first = transducer.migrate(&tokens);
let second = transducer.migrate(&first.migrated);
prop_assert_eq!(first.migrated, second.migrated);
}
#[test]
fn empty_input_yields_empty_output(
old in arb_ident(),
new in arb_ident(),
) {
let mut transducer: ApiMigrationTransducer<crate::semiring::TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
&old,
&new,
Version::new(1, 0),
Version::new(2, 0),
));
let result = transducer.migrate(&[]);
prop_assert!(result.migrated.is_empty());
prop_assert_eq!(result.stats.rules_applied, 0);
}
#[test]
fn no_match_preserves_input(
old in arb_ident(),
new in arb_ident(),
input in proptest::collection::vec(arb_ident(), 0..6),
) {
let mut transducer: ApiMigrationTransducer<crate::semiring::TropicalWeight> =
ApiMigrationTransducer::new(Version::new(1, 0), Version::new(2, 0));
transducer.add_rule(ApiMigrationRule::rename_function(
&old,
&new,
Version::new(1, 0),
Version::new(2, 0),
));
prop_assume!(!input.contains(&old));
let result = transducer.migrate(&input);
prop_assert_eq!(result.migrated, input);
prop_assert_eq!(result.stats.rules_applied, 0);
}
}
}
}