use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{Error, Result};
use crate::git;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductionChange {
pub change_type: ChangeType,
pub file: String,
pub old_symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_symbol: Option<String>,
#[serde(default)]
pub line: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChangeType {
MethodRename,
MethodRemoved,
ClassRename,
ClassRemoved,
ErrorCodeChange,
ReturnTypeChange,
SignatureChange,
FileMove,
StringChange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftedTest {
pub test_file: String,
pub line: usize,
pub content: String,
pub change_index: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DriftReport {
pub component: String,
pub since: String,
pub production_changes: Vec<ProductionChange>,
pub drifted_tests: Vec<DriftedTest>,
pub total_drifted_files: usize,
pub total_drift_references: usize,
pub auto_fixable: usize,
}
pub struct DriftOptions<'a> {
pub root: &'a Path,
pub since: &'a str,
pub source_patterns: Vec<String>,
pub test_patterns: Vec<String>,
}
impl<'a> DriftOptions<'a> {
pub fn php(root: &'a Path, since: &'a str) -> Self {
Self {
root,
since,
source_patterns: vec![
"src/**/*.php".into(),
"inc/**/*.php".into(),
"lib/**/*.php".into(),
],
test_patterns: vec!["tests/**/*.php".into()],
}
}
pub fn rust(root: &'a Path, since: &'a str) -> Self {
Self {
root,
since,
source_patterns: vec!["src/**/*.rs".into()],
test_patterns: vec!["tests/**/*.rs".into()],
}
}
}
pub fn detect_drift(component: &str, opts: &DriftOptions) -> Result<DriftReport> {
let changed_files = get_changed_files(opts.root, opts.since)?;
let prod_files: Vec<&str> = changed_files
.iter()
.filter(|f| !is_test_file(f))
.map(|s| s.as_str())
.collect();
if prod_files.is_empty() {
return Ok(DriftReport {
component: component.to_string(),
since: opts.since.to_string(),
production_changes: Vec::new(),
drifted_tests: Vec::new(),
total_drifted_files: 0,
total_drift_references: 0,
auto_fixable: 0,
});
}
let mut changes = Vec::new();
for file in &prod_files {
let diff = get_file_diff(opts.root, opts.since, file)?;
let file_changes = extract_changes_from_diff(file, &diff);
changes.extend(file_changes);
}
let renames = get_renamed_files(opts.root, opts.since)?;
for (old, new) in &renames {
if !is_test_file(old) {
changes.push(ProductionChange {
change_type: ChangeType::FileMove,
file: new.clone(),
old_symbol: old.clone(),
new_symbol: Some(new.clone()),
line: 0,
});
}
}
let test_files = collect_test_files(opts.root);
let drifted = find_drift_references(&changes, &test_files, opts.root);
let total_drifted_files = {
let unique: std::collections::HashSet<&str> =
drifted.iter().map(|d| d.test_file.as_str()).collect();
unique.len()
};
let total_drift_references = drifted.len();
let auto_fixable = changes.iter().filter(|c| is_auto_fixable(c)).count();
Ok(DriftReport {
component: component.to_string(),
since: opts.since.to_string(),
production_changes: changes,
drifted_tests: drifted,
total_drifted_files,
total_drift_references,
auto_fixable,
})
}
fn get_changed_files(root: &Path, since: &str) -> Result<Vec<String>> {
let root_str = root.to_string_lossy();
git::get_files_changed_since(&root_str, since)
}
fn get_file_diff(root: &Path, since: &str, file: &str) -> Result<String> {
let output = Command::new("git")
.args(["diff", since, "HEAD", "--", file])
.current_dir(root)
.output()
.map_err(|e| {
Error::internal_io(
format!("Failed to run git diff for {}: {}", file, e),
Some("test_drift.git".to_string()),
)
})?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn get_renamed_files(root: &Path, since: &str) -> Result<Vec<(String, String)>> {
let output = Command::new("git")
.args(["diff", "--diff-filter=R", "--name-status", since, "HEAD"])
.current_dir(root)
.output()
.map_err(|e| {
Error::internal_io(
format!("Failed to get renamed files: {}", e),
Some("test_drift.git".to_string()),
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut renames = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 && parts[0].starts_with('R') {
renames.push((parts[1].to_string(), parts[2].to_string()));
}
}
Ok(renames)
}
fn extract_changes_from_diff(file: &str, diff: &str) -> Vec<ProductionChange> {
let mut changes = Vec::new();
let mut removed_methods: Vec<(String, usize)> = Vec::new();
let mut added_methods: Vec<(String, usize)> = Vec::new();
let mut removed_classes: Vec<(String, usize)> = Vec::new();
let mut added_classes: Vec<(String, usize)> = Vec::new();
let mut removed_strings: Vec<(String, usize)> = Vec::new();
let mut added_strings: Vec<(String, usize)> = Vec::new();
let method_re = Regex::new(
r"(?:public|protected|private|static|abstract|final)\s+(?:static\s+)?function\s+(\w+)",
)
.unwrap();
let class_re = Regex::new(r"(?:abstract\s+)?(?:class|trait|interface)\s+(\w+)").unwrap();
let string_re = Regex::new(r#"'([a-z_]{3,50})'"#).unwrap();
let rust_fn_re = Regex::new(r"(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)").unwrap();
let rust_struct_re =
Regex::new(r"(?:pub(?:\(crate\))?\s+)?(?:struct|enum|trait)\s+(\w+)").unwrap();
let is_rust = file.ends_with(".rs");
let fn_re = if is_rust { &rust_fn_re } else { &method_re };
let cls_re = if is_rust { &rust_struct_re } else { &class_re };
let hunk_re = Regex::new(r"@@ -\d+(?:,\d+)? \+(\d+)").unwrap();
let mut line_num: usize = 0;
for line in diff.lines() {
if line.starts_with("@@") {
if let Some(cap) = hunk_re.captures(line) {
line_num = cap[1].parse().unwrap_or(0);
}
continue;
}
if line.starts_with('-') && !line.starts_with("---") {
let content = &line[1..];
if let Some(cap) = fn_re.captures(content) {
removed_methods.push((cap[1].to_string(), line_num));
}
if let Some(cap) = cls_re.captures(content) {
removed_classes.push((cap[1].to_string(), line_num));
}
for cap in string_re.captures_iter(content) {
removed_strings.push((cap[1].to_string(), line_num));
}
} else if line.starts_with('+') && !line.starts_with("+++") {
let content = &line[1..];
if let Some(cap) = fn_re.captures(content) {
added_methods.push((cap[1].to_string(), line_num));
}
if let Some(cap) = cls_re.captures(content) {
added_classes.push((cap[1].to_string(), line_num));
}
for cap in string_re.captures_iter(content) {
added_strings.push((cap[1].to_string(), line_num));
}
line_num += 1;
} else if !line.starts_with('\\') {
line_num += 1;
}
}
let mut matched_removed: Vec<bool> = vec![false; removed_methods.len()];
let mut matched_added: Vec<bool> = vec![false; added_methods.len()];
for (ri, (removed, rline)) in removed_methods.iter().enumerate() {
for (ai, (added, aline)) in added_methods.iter().enumerate() {
if !matched_added[ai] && removed != added {
let dist = (*aline as isize - *rline as isize).unsigned_abs();
if dist <= 10 {
changes.push(ProductionChange {
change_type: ChangeType::MethodRename,
file: file.to_string(),
old_symbol: removed.clone(),
new_symbol: Some(added.clone()),
line: *rline,
});
matched_removed[ri] = true;
matched_added[ai] = true;
break;
}
}
}
}
for (ri, (removed, rline)) in removed_methods.iter().enumerate() {
if !matched_removed[ri] {
changes.push(ProductionChange {
change_type: ChangeType::MethodRemoved,
file: file.to_string(),
old_symbol: removed.clone(),
new_symbol: None,
line: *rline,
});
}
}
let mut cls_matched_removed: Vec<bool> = vec![false; removed_classes.len()];
let mut cls_matched_added: Vec<bool> = vec![false; added_classes.len()];
for (ri, (removed, rline)) in removed_classes.iter().enumerate() {
for (ai, (added, aline)) in added_classes.iter().enumerate() {
if !cls_matched_added[ai] && removed != added {
let dist = (*aline as isize - *rline as isize).unsigned_abs();
if dist <= 15 {
changes.push(ProductionChange {
change_type: ChangeType::ClassRename,
file: file.to_string(),
old_symbol: removed.clone(),
new_symbol: Some(added.clone()),
line: *rline,
});
cls_matched_removed[ri] = true;
cls_matched_added[ai] = true;
break;
}
}
}
}
for (ri, (removed, rline)) in removed_classes.iter().enumerate() {
if !cls_matched_removed[ri] {
changes.push(ProductionChange {
change_type: ChangeType::ClassRemoved,
file: file.to_string(),
old_symbol: removed.clone(),
new_symbol: None,
line: *rline,
});
}
}
let mut str_matched_removed: Vec<bool> = vec![false; removed_strings.len()];
for (ri, (removed, rline)) in removed_strings.iter().enumerate() {
for (added, aline) in &added_strings {
if removed != added {
let dist = (*aline as isize - *rline as isize).unsigned_abs();
if dist <= 5 {
changes.push(ProductionChange {
change_type: ChangeType::ErrorCodeChange,
file: file.to_string(),
old_symbol: removed.clone(),
new_symbol: Some(added.clone()),
line: *rline,
});
str_matched_removed[ri] = true;
break;
}
}
}
}
changes
}
fn is_test_file(path: &str) -> bool {
path.contains("/tests/") || path.contains("Test.php") || path.contains("_test.rs")
}
fn collect_test_files(root: &Path) -> Vec<PathBuf> {
let tests_dir = root.join("tests");
if !tests_dir.exists() {
return Vec::new();
}
let mut files = Vec::new();
collect_files_recursive(&tests_dir, &mut files);
files
}
fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name == ".git" || name == "node_modules" || name == "vendor" {
continue;
}
collect_files_recursive(&path, files);
} else if path.is_file() {
files.push(path);
}
}
}
fn find_drift_references(
changes: &[ProductionChange],
test_files: &[PathBuf],
root: &Path,
) -> Vec<DriftedTest> {
let mut drifted = Vec::new();
for (ci, change) in changes.iter().enumerate() {
if change.old_symbol.len() < 3 {
continue;
}
let search = &change.old_symbol;
for test_file in test_files {
let Ok(content) = std::fs::read_to_string(test_file) else {
continue;
};
let relative = test_file
.strip_prefix(root)
.unwrap_or(test_file)
.to_string_lossy()
.to_string();
for (i, line) in content.lines().enumerate() {
if line.contains(search) {
let trimmed = line.trim();
if trimmed.starts_with("//")
|| trimmed.starts_with('#')
|| trimmed.starts_with('*')
|| trimmed.starts_with("/*")
{
continue;
}
drifted.push(DriftedTest {
test_file: relative.clone(),
line: i + 1,
content: line.trim().to_string(),
change_index: ci,
});
}
}
}
}
drifted
}
fn is_auto_fixable(change: &ProductionChange) -> bool {
match change.change_type {
ChangeType::MethodRename => change.new_symbol.is_some(),
ChangeType::ClassRename => change.new_symbol.is_some(),
ChangeType::ErrorCodeChange => change.new_symbol.is_some(),
ChangeType::FileMove => change.new_symbol.is_some(),
ChangeType::StringChange => change.new_symbol.is_some(),
ChangeType::MethodRemoved => false,
ChangeType::ClassRemoved => false,
ChangeType::ReturnTypeChange => false,
ChangeType::SignatureChange => false,
}
}
pub fn generate_transform_rules(report: &DriftReport) -> Vec<crate::refactor::TransformRule> {
let mut rules = Vec::new();
for change in &report.production_changes {
if !is_auto_fixable(change) {
continue;
}
let new_symbol = match &change.new_symbol {
Some(s) => s,
None => continue,
};
let id = format!("{:?}_{}", change.change_type, change.old_symbol)
.to_lowercase()
.replace(' ', "_");
let description = match change.change_type {
ChangeType::MethodRename => {
format!(
"Rename {} → {} ({})",
change.old_symbol, new_symbol, change.file
)
}
ChangeType::ClassRename => {
format!(
"Rename class {} → {} ({})",
change.old_symbol, new_symbol, change.file
)
}
ChangeType::ErrorCodeChange => {
format!(
"Error code {} → {} ({})",
change.old_symbol, new_symbol, change.file
)
}
ChangeType::FileMove => {
format!("File moved {} → {}", change.old_symbol, new_symbol)
}
_ => format!("{} → {} ({})", change.old_symbol, new_symbol, change.file),
};
let (find, replace) = match change.change_type {
ChangeType::MethodRename | ChangeType::ClassRename => {
if !is_identifier(&change.old_symbol) || !is_identifier(new_symbol) {
continue;
}
(
format!(r"\b{}\b", regex::escape(&change.old_symbol)),
new_symbol.clone(),
)
}
ChangeType::ErrorCodeChange | ChangeType::StringChange => {
if !is_safe_literal_token(&change.old_symbol) || !is_safe_literal_token(new_symbol)
{
continue;
}
(regex::escape(&change.old_symbol), new_symbol.clone())
}
ChangeType::FileMove => {
if !looks_like_path(&change.old_symbol) || !looks_like_path(new_symbol) {
continue;
}
(regex::escape(&change.old_symbol), new_symbol.clone())
}
_ => continue,
};
rules.push(crate::refactor::TransformRule {
id,
description,
find,
replace,
files: "tests/**/*".to_string(),
context: "line".to_string(),
});
}
rules
}
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn is_safe_literal_token(s: &str) -> bool {
if s.len() < 4 {
return false;
}
if s.contains('/') || s.contains('\\') || s.contains(':') || s.contains('.') || s.contains('-')
{
return true;
}
if s.contains('_') {
let lower = s.to_ascii_lowercase();
return lower.starts_with("rest_")
|| lower.starts_with("ability_")
|| lower.contains("error")
|| lower.contains("invalid")
|| lower.contains("forbidden")
|| lower.contains("denied")
|| lower.contains("not_found")
|| lower.contains("failed");
}
false
}
fn looks_like_path(s: &str) -> bool {
s.len() >= 5 && (s.contains('/') || s.contains('\\'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_method_rename() {
let diff = r#"@@ -10,7 +10,7 @@
- public function executeRunFlow($id) {
+ public function executeWorkflow($id) {
return $this->doWork($id);
}
"#;
let changes = extract_changes_from_diff("src/Abilities/JobAbilities.php", diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, ChangeType::MethodRename);
assert_eq!(changes[0].old_symbol, "executeRunFlow");
assert_eq!(changes[0].new_symbol.as_deref(), Some("executeWorkflow"));
}
#[test]
fn extract_method_removed() {
let diff = r#"@@ -20,5 +20,0 @@
- public function oldHelper() {
- return true;
- }
"#;
let changes = extract_changes_from_diff("src/Helper.php", diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, ChangeType::MethodRemoved);
assert_eq!(changes[0].old_symbol, "oldHelper");
assert!(changes[0].new_symbol.is_none());
}
#[test]
fn extract_error_code_change() {
let diff = r#"@@ -5,7 +5,7 @@
- return new WP_Error('rest_forbidden', 'Access denied');
+ return new WP_Error('ability_invalid_permissions', 'Access denied');
"#;
let changes = extract_changes_from_diff("src/REST/Auth.php", diff);
let code_changes: Vec<_> = changes
.iter()
.filter(|c| c.change_type == ChangeType::ErrorCodeChange)
.collect();
assert!(!code_changes.is_empty());
assert_eq!(code_changes[0].old_symbol, "rest_forbidden");
assert_eq!(
code_changes[0].new_symbol.as_deref(),
Some("ability_invalid_permissions")
);
}
#[test]
fn extract_class_rename() {
let diff = r#"@@ -1,5 +1,5 @@
-class FlowsCommand extends BaseCommand {
+class FlowCommand extends BaseCommand {
public function handle() {
"#;
let changes = extract_changes_from_diff("src/Commands/FlowsCommand.php", diff);
let cls = changes
.iter()
.find(|c| c.change_type == ChangeType::ClassRename)
.unwrap();
assert_eq!(cls.old_symbol, "FlowsCommand");
assert_eq!(cls.new_symbol.as_deref(), Some("FlowCommand"));
}
#[test]
fn extract_rust_fn_rename() {
let diff = r#"@@ -10,7 +10,7 @@
-pub fn load_config(path: &Path) -> Config {
+pub fn read_config(path: &Path) -> Config {
let data = fs::read_to_string(path).unwrap();
"#;
let changes = extract_changes_from_diff("src/config.rs", diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, ChangeType::MethodRename);
assert_eq!(changes[0].old_symbol, "load_config");
assert_eq!(changes[0].new_symbol.as_deref(), Some("read_config"));
}
#[test]
fn is_test_file_detection() {
assert!(is_test_file("tests/Unit/FooTest.php"));
assert!(is_test_file("tests/integration/bar_test.rs"));
assert!(!is_test_file("src/Foo.php"));
assert!(!is_test_file("src/config.rs"));
}
#[test]
fn auto_fixable_detection() {
let rename = ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/Foo.php".into(),
old_symbol: "oldMethod".into(),
new_symbol: Some("newMethod".into()),
line: 10,
};
assert!(is_auto_fixable(&rename));
let removed = ProductionChange {
change_type: ChangeType::MethodRemoved,
file: "src/Foo.php".into(),
old_symbol: "deadMethod".into(),
new_symbol: None,
line: 10,
};
assert!(!is_auto_fixable(&removed));
}
#[test]
fn generate_rules_from_rename() {
let report = DriftReport {
component: "test".into(),
since: "v1.0".into(),
production_changes: vec![ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/Foo.php".into(),
old_symbol: "executeRunFlow".into(),
new_symbol: Some("executeWorkflow".into()),
line: 10,
}],
drifted_tests: Vec::new(),
total_drifted_files: 0,
total_drift_references: 0,
auto_fixable: 1,
};
let rules = generate_transform_rules(&report);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].find, r"\bexecuteRunFlow\b");
assert_eq!(rules[0].replace, "executeWorkflow");
assert_eq!(rules[0].files, "tests/**/*");
}
#[test]
fn generate_rules_skips_unsafe_generic_string_changes() {
let report = DriftReport {
component: "test".into(),
since: "v1.0".into(),
production_changes: vec![ProductionChange {
change_type: ChangeType::ErrorCodeChange,
file: "src/Foo.php".into(),
old_symbol: "name".into(),
new_symbol: Some("assistant".into()),
line: 10,
}],
drifted_tests: Vec::new(),
total_drifted_files: 0,
total_drift_references: 0,
auto_fixable: 1,
};
let rules = generate_transform_rules(&report);
assert!(rules.is_empty());
}
#[test]
fn generate_rules_keeps_safe_error_code_tokens() {
let report = DriftReport {
component: "test".into(),
since: "v1.0".into(),
production_changes: vec![ProductionChange {
change_type: ChangeType::ErrorCodeChange,
file: "src/Foo.php".into(),
old_symbol: "rest_forbidden".into(),
new_symbol: Some("ability_invalid_permissions".into()),
line: 10,
}],
drifted_tests: Vec::new(),
total_drifted_files: 0,
total_drift_references: 0,
auto_fixable: 1,
};
let rules = generate_transform_rules(&report);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].find, "rest_forbidden");
assert_eq!(rules[0].replace, "ability_invalid_permissions");
}
#[test]
fn generate_rules_skips_non_error_underscore_tokens() {
let report = DriftReport {
component: "test".into(),
since: "v1.0".into(),
production_changes: vec![ProductionChange {
change_type: ChangeType::ErrorCodeChange,
file: "src/Foo.php".into(),
old_symbol: "flow_id".into(),
new_symbol: Some("pipeline_id".into()),
line: 10,
}],
drifted_tests: Vec::new(),
total_drifted_files: 0,
total_drift_references: 0,
auto_fixable: 1,
};
let rules = generate_transform_rules(&report);
assert!(rules.is_empty());
}
#[test]
fn skip_short_symbols() {
let changes = vec![ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/X.php".into(),
old_symbol: "ab".into(), new_symbol: Some("cd".into()),
line: 1,
}];
let test_content = "line with ab in it\n";
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let tests_dir = root.join("tests");
std::fs::create_dir_all(&tests_dir).unwrap();
std::fs::write(tests_dir.join("FooTest.php"), test_content).unwrap();
let test_files = vec![tests_dir.join("FooTest.php")];
let drifted = find_drift_references(&changes, &test_files, root);
assert!(drifted.is_empty()); }
#[test]
fn find_references_in_test_files() {
let changes = vec![ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/Foo.php".into(),
old_symbol: "executeRunFlow".into(),
new_symbol: Some("executeWorkflow".into()),
line: 10,
}];
let test_content = r#"<?php
class FooTest extends TestCase {
public function testRunFlow() {
$result = $this->foo->executeRunFlow(1);
$this->assertNotNull($result);
}
}
"#;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let tests_dir = root.join("tests");
std::fs::create_dir_all(&tests_dir).unwrap();
std::fs::write(tests_dir.join("FooTest.php"), test_content).unwrap();
let test_files = vec![tests_dir.join("FooTest.php")];
let drifted = find_drift_references(&changes, &test_files, root);
assert_eq!(drifted.len(), 1);
assert_eq!(drifted[0].line, 4);
assert!(drifted[0].content.contains("executeRunFlow"));
}
#[test]
fn multiple_changes_multiple_tests() {
let changes = vec![
ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/A.php".into(),
old_symbol: "oldMethodA".into(),
new_symbol: Some("newMethodA".into()),
line: 5,
},
ProductionChange {
change_type: ChangeType::ErrorCodeChange,
file: "src/B.php".into(),
old_symbol: "rest_forbidden".into(),
new_symbol: Some("access_denied".into()),
line: 10,
},
];
let test1 = "<?php\n$this->oldMethodA();\n";
let test2 = "<?php\nassertEquals('rest_forbidden', $code);\n";
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let tests_dir = root.join("tests");
std::fs::create_dir_all(&tests_dir).unwrap();
std::fs::write(tests_dir.join("ATest.php"), test1).unwrap();
std::fs::write(tests_dir.join("BTest.php"), test2).unwrap();
let test_files = vec![tests_dir.join("ATest.php"), tests_dir.join("BTest.php")];
let drifted = find_drift_references(&changes, &test_files, root);
assert_eq!(drifted.len(), 2);
}
#[test]
fn skip_comment_lines() {
let changes = vec![ProductionChange {
change_type: ChangeType::MethodRename,
file: "src/Foo.php".into(),
old_symbol: "oldMethod".into(),
new_symbol: Some("newMethod".into()),
line: 5,
}];
let test_content =
"<?php\n// oldMethod was renamed\n/* oldMethod docs */\n$this->oldMethod();\n";
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let tests_dir = root.join("tests");
std::fs::create_dir_all(&tests_dir).unwrap();
std::fs::write(tests_dir.join("FooTest.php"), test_content).unwrap();
let test_files = vec![tests_dir.join("FooTest.php")];
let drifted = find_drift_references(&changes, &test_files, root);
assert_eq!(drifted.len(), 1); assert_eq!(drifted[0].line, 4);
}
}