use crate::changes::ChangeRecord;
use aho_corasick::AhoCorasick;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferenceFix {
pub file: String,
pub line: usize,
pub column: usize,
pub context: String,
pub old_reference: String,
pub new_reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixRecord {
pub generated_from: String,
pub timestamp: String,
pub scan_directories: Vec<String>,
pub fixes: Vec<ReferenceFix>,
}
impl FixRecord {
pub fn new(generated_from: &str, scan_directories: &[PathBuf]) -> Self {
let timestamp = chrono::Utc::now().to_rfc3339();
FixRecord {
generated_from: generated_from.to_string(),
timestamp,
scan_directories: scan_directories
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect(),
fixes: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.fixes.is_empty()
}
pub fn len(&self) -> usize {
self.fixes.len()
}
pub fn write_to_file(&self, path: &Path) -> crate::Result<()> {
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json)?;
Ok(())
}
pub fn read_from_file(path: &Path) -> crate::Result<Self> {
let json = fs::read_to_string(path)?;
let record: FixRecord = serde_json::from_str(&json)?;
Ok(record)
}
}
#[derive(Debug, Clone)]
pub struct ScanOptions {
pub extensions: Vec<String>,
pub exclude_patterns: Vec<String>,
pub recursive: bool,
pub verbose: bool,
}
impl Default for ScanOptions {
fn default() -> Self {
ScanOptions {
extensions: vec![
".go".to_string(),
".py".to_string(),
".js".to_string(),
".ts".to_string(),
".jsx".to_string(),
".tsx".to_string(),
".rs".to_string(),
".java".to_string(),
".c".to_string(),
".cpp".to_string(),
".h".to_string(),
".hpp".to_string(),
".html".to_string(),
".tmpl".to_string(),
".yaml".to_string(),
".yml".to_string(),
".json".to_string(),
".toml".to_string(),
".xml".to_string(),
".md".to_string(),
".txt".to_string(),
".cfg".to_string(),
".conf".to_string(),
".ini".to_string(),
],
exclude_patterns: vec![
".git".to_string(),
"node_modules".to_string(),
"target".to_string(),
"vendor".to_string(),
"__pycache__".to_string(),
".venv".to_string(),
"dist".to_string(),
"build".to_string(),
],
recursive: true,
verbose: false,
}
}
}
pub struct ReferenceScanner {
options: ScanOptions,
file_moves: HashMap<String, String>,
automaton: AhoCorasick,
patterns: Vec<String>,
}
impl ReferenceScanner {
pub fn from_change_record(record: &ChangeRecord, options: ScanOptions) -> Self {
let mut file_moves = HashMap::new();
for (from, to) in record.file_moves() {
let from_filename = Path::new(from)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(from);
file_moves.insert(from_filename.to_string(), to.to_string());
if from != from_filename {
file_moves.insert(from.to_string(), to.to_string());
}
}
Self::new(file_moves, options)
}
pub fn new(file_moves: HashMap<String, String>, options: ScanOptions) -> Self {
let patterns: Vec<String> = file_moves.keys().cloned().collect();
let automaton =
AhoCorasick::new(&patterns).expect("Failed to build Aho-Corasick automaton");
ReferenceScanner {
options,
file_moves,
automaton,
patterns,
}
}
fn should_include_entry(
entry: &walkdir::DirEntry,
exclude_patterns: &[String],
verbose: bool,
) -> bool {
let name = match entry.file_name().to_str() {
Some(n) => n,
None => return false, };
if name.starts_with('.') {
if verbose && entry.file_type().is_dir() {
eprintln!(" [skip] {} (hidden)", entry.path().display());
}
return false;
}
if exclude_patterns.iter().any(|p| p == name) {
if verbose && entry.file_type().is_dir() {
eprintln!(
" [skip] {} (excluded pattern: {})",
entry.path().display(),
name
);
}
return false;
}
true
}
fn should_scan_file(&self, path: &Path) -> bool {
if self.options.extensions.is_empty() {
return true;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_with_dot = format!(".{}", ext);
self.options.extensions.iter().any(|e| e == &ext_with_dot)
} else {
false
}
}
fn scan_file(&self, path: &Path) -> crate::Result<Vec<ReferenceFix>> {
let content = fs::read_to_string(path)?;
if self.patterns.is_empty() {
return Ok(Vec::new());
}
let line_starts: Vec<usize> = std::iter::once(0)
.chain(content.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let mut fixes = Vec::new();
let file_path_str = path.to_string_lossy().to_string();
for mat in self.automaton.find_iter(&content) {
let pattern_idx = mat.pattern().as_usize();
let old_ref = &self.patterns[pattern_idx];
let new_ref = match self.file_moves.get(old_ref) {
Some(r) => r,
None => continue,
};
let byte_pos = mat.start();
let line_idx = line_starts.partition_point(|&start| start <= byte_pos) - 1;
let line_start = line_starts[line_idx];
let column = byte_pos - line_start;
let line_end = line_starts
.get(line_idx + 1)
.map(|&s| s.saturating_sub(1))
.unwrap_or(content.len());
let line_content = &content[line_start..line_end];
fixes.push(ReferenceFix {
file: file_path_str.clone(),
line: line_idx + 1,
column: column + 1,
context: line_content.trim().to_string(),
old_reference: old_ref.clone(),
new_reference: new_ref.clone(),
});
}
Ok(fixes)
}
pub fn scan(&self, directories: &[PathBuf]) -> crate::Result<FixRecord> {
let mut fix_record = FixRecord::new("changes.json", directories);
let verbose = self.options.verbose;
let mut files_scanned = 0;
for dir in directories {
if !dir.exists() {
if verbose {
eprintln!("[scan] Directory does not exist: {}", dir.display());
}
continue;
}
if verbose {
eprintln!("[scan] Starting scan of: {}", dir.display());
}
let walker = if self.options.recursive {
WalkDir::new(dir)
} else {
WalkDir::new(dir).max_depth(1)
};
let exclude_patterns = &self.options.exclude_patterns;
let walker = walker
.into_iter()
.filter_entry(|e| Self::should_include_entry(e, exclude_patterns, verbose));
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if verbose && entry.file_type().is_dir() {
eprintln!("[scan] Entering directory: {}", path.display());
continue;
}
if !path.is_file() {
continue;
}
if !self.should_scan_file(path) {
if verbose {
eprintln!(" [skip] {} (extension not in scan list)", path.display());
}
continue;
}
if verbose {
eprintln!(" [file] {}", path.display());
}
files_scanned += 1;
match self.scan_file(path) {
Ok(fixes) => {
if verbose && !fixes.is_empty() {
eprintln!(" -> Found {} reference(s)", fixes.len());
}
fix_record.fixes.extend(fixes);
}
Err(e) => {
if verbose {
eprintln!(" -> Error: {}", e);
}
log::debug!("Skipping {}: {}", path.display(), e);
}
}
}
}
if verbose {
eprintln!(
"[scan] Complete. Scanned {} files, found {} references.",
files_scanned,
fix_record.fixes.len()
);
}
fix_record
.fixes
.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
fix_record.fixes.dedup_by(|a, b| {
a.file == b.file && a.line == b.line && a.old_reference == b.old_reference
});
Ok(fix_record)
}
}
pub struct ReferenceFixer;
impl ReferenceFixer {
pub fn apply_fixes(fix_record: &FixRecord) -> crate::Result<ApplyResult> {
let mut result = ApplyResult::default();
let mut fixes_by_file: HashMap<&str, Vec<&ReferenceFix>> = HashMap::new();
for fix in &fix_record.fixes {
fixes_by_file.entry(&fix.file).or_default().push(fix);
}
for (file_path, fixes) in fixes_by_file {
match Self::apply_fixes_to_file(Path::new(file_path), &fixes) {
Ok(count) => {
result.files_modified += 1;
result.references_fixed += count;
}
Err(e) => {
result.errors.push(format!("{}: {}", file_path, e));
}
}
}
Ok(result)
}
fn apply_fixes_to_file(path: &Path, fixes: &[&ReferenceFix]) -> crate::Result<usize> {
let content = fs::read_to_string(path)?;
let mut new_content = content.clone();
let mut fixed_count = 0;
for fix in fixes {
let old = &fix.old_reference;
let new = &fix.new_reference;
if new_content.contains(old) {
new_content = new_content.replace(old, new);
fixed_count += 1;
}
}
if new_content != content {
fs::write(path, new_content)?;
}
Ok(fixed_count)
}
pub fn dry_run(fix_record: &FixRecord) -> Vec<String> {
fix_record
.fixes
.iter()
.map(|fix| {
format!(
"{}:{}: '{}' -> '{}'",
fix.file, fix.line, fix.old_reference, fix.new_reference
)
})
.collect()
}
}
#[derive(Debug, Default)]
pub struct ApplyResult {
pub files_modified: usize,
pub references_fixed: usize,
pub errors: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn create_test_dir(name: &str) -> PathBuf {
let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let test_dir = std::env::temp_dir().join(format!(
"reformat_refs_{}_{}_{}",
name,
std::process::id(),
counter
));
let _ = fs::remove_dir_all(&test_dir);
fs::create_dir_all(&test_dir).unwrap();
test_dir
}
#[test]
fn test_find_reference_quoted() {
let test_dir = create_test_dir("quoted");
let mut moves = HashMap::new();
moves.insert("old.tmpl".to_string(), "new/old.tmpl".to_string());
let scanner = ReferenceScanner::new(moves, ScanOptions::default());
let file1 = test_dir.join("test1.go");
fs::write(&file1, r#"include "old.tmpl""#).unwrap();
let fixes = scanner.scan_file(&file1).unwrap();
assert_eq!(fixes.len(), 1);
let file2 = test_dir.join("test2.go");
fs::write(&file2, r#"include 'old.tmpl'"#).unwrap();
let fixes = scanner.scan_file(&file2).unwrap();
assert_eq!(fixes.len(), 1);
let file3 = test_dir.join("test3.yaml");
fs::write(&file3, "template: old.tmpl").unwrap();
let fixes = scanner.scan_file(&file3).unwrap();
assert_eq!(fixes.len(), 1);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_scan_file() {
let test_dir = create_test_dir("scan");
let test_file = test_dir.join("handler.go");
fs::write(
&test_file,
r#"
package main
func render() {
t := template.ParseFiles("wbs_create.tmpl")
t2 := template.ParseFiles("wbs_delete.tmpl")
}
"#,
)
.unwrap();
let mut moves = HashMap::new();
moves.insert("wbs_create.tmpl".to_string(), "wbs/create.tmpl".to_string());
moves.insert("wbs_delete.tmpl".to_string(), "wbs/delete.tmpl".to_string());
let scanner = ReferenceScanner::new(moves, ScanOptions::default());
let fixes = scanner.scan_file(&test_file).unwrap();
assert_eq!(fixes.len(), 2);
assert_eq!(fixes[0].old_reference, "wbs_create.tmpl");
assert_eq!(fixes[0].new_reference, "wbs/create.tmpl");
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_scan_directories() {
let test_dir = create_test_dir("scandir");
fs::write(
test_dir.join("main.go"),
r#"
include "old_file.tmpl"
"#,
)
.unwrap();
fs::write(
test_dir.join("config.yaml"),
r#"
template: old_file.tmpl
"#,
)
.unwrap();
let mut moves = HashMap::new();
moves.insert(
"old_file.tmpl".to_string(),
"templates/file.tmpl".to_string(),
);
let scanner = ReferenceScanner::new(moves, ScanOptions::default());
let fix_record = scanner.scan(&[test_dir.clone()]).unwrap();
assert_eq!(fix_record.len(), 2);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_apply_fixes() {
let test_dir = create_test_dir("apply");
let test_file = test_dir.join("test.go");
fs::write(&test_file, r#"include "old.tmpl""#).unwrap();
let fix_record = FixRecord {
generated_from: "test".to_string(),
timestamp: "2026-01-15T00:00:00Z".to_string(),
scan_directories: vec![test_dir.to_string_lossy().to_string()],
fixes: vec![ReferenceFix {
file: test_file.to_string_lossy().to_string(),
line: 1,
column: 10,
context: r#"include "old.tmpl""#.to_string(),
old_reference: "old.tmpl".to_string(),
new_reference: "new/old.tmpl".to_string(),
}],
};
let result = ReferenceFixer::apply_fixes(&fix_record).unwrap();
assert_eq!(result.files_modified, 1);
assert_eq!(result.references_fixed, 1);
let content = fs::read_to_string(&test_file).unwrap();
assert!(content.contains("new/old.tmpl"));
assert!(!content.contains(r#""old.tmpl""#));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_fix_record_serialization() {
let fix_record = FixRecord {
generated_from: "changes.json".to_string(),
timestamp: "2026-01-15T00:00:00Z".to_string(),
scan_directories: vec!["/tmp/src".to_string()],
fixes: vec![ReferenceFix {
file: "/tmp/src/main.go".to_string(),
line: 10,
column: 15,
context: r#"include "old.tmpl""#.to_string(),
old_reference: "old.tmpl".to_string(),
new_reference: "new/old.tmpl".to_string(),
}],
};
let json = serde_json::to_string_pretty(&fix_record).unwrap();
assert!(json.contains("\"generated_from\": \"changes.json\""));
assert!(json.contains("\"old_reference\": \"old.tmpl\""));
let parsed: FixRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.fixes.len(), 1);
}
#[test]
fn test_exclude_patterns() {
let test_dir = create_test_dir("exclude");
let node_modules = test_dir.join("node_modules");
let git_dir = test_dir.join(".git");
let src_dir = test_dir.join("src");
fs::create_dir_all(&node_modules).unwrap();
fs::create_dir_all(&git_dir).unwrap();
fs::create_dir_all(&src_dir).unwrap();
fs::write(node_modules.join("index.js"), "require('old.tmpl')").unwrap();
fs::write(git_dir.join("config"), "path = old.tmpl").unwrap();
fs::write(src_dir.join("main.rs"), r#"include!("old.tmpl")"#).unwrap();
let mut moves = HashMap::new();
moves.insert("old.tmpl".to_string(), "new/old.tmpl".to_string());
let scanner = ReferenceScanner::new(moves, ScanOptions::default());
let fix_record = scanner.scan(&[test_dir.clone()]).unwrap();
assert_eq!(fix_record.len(), 1);
assert!(fix_record.fixes[0].file.contains("src"));
let _ = fs::remove_dir_all(&test_dir);
}
}