use crate::error::{Error, Result};
use crate::utils::codebase_scan::{
self, find_boundary_matches, find_case_insensitive_matches, find_literal_matches,
ExtensionFilter, ScanConfig,
};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenameScope {
Code,
Config,
All,
}
impl RenameScope {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self> {
match s {
"code" => Ok(RenameScope::Code),
"config" => Ok(RenameScope::Config),
"all" => Ok(RenameScope::All),
_ => Err(Error::validation_invalid_argument(
"scope",
format!("Unknown scope '{}'. Use: code, config, all", s),
None,
None,
)),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CaseVariant {
pub from: String,
pub to: String,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct RenameSpec {
pub from: String,
pub to: String,
pub scope: RenameScope,
pub variants: Vec<CaseVariant>,
pub literal: bool,
}
#[derive(Debug, Clone)]
pub struct RenameTargeting {
pub include_globs: Vec<String>,
pub exclude_globs: Vec<String>,
pub rename_files: bool,
}
impl Default for RenameTargeting {
fn default() -> Self {
Self {
include_globs: Vec::new(),
exclude_globs: Vec::new(),
rename_files: true,
}
}
}
impl RenameSpec {
pub fn new(from: &str, to: &str, scope: RenameScope) -> Self {
let from_words = split_words(from);
let to_words = split_words(to);
let mut variants = Vec::new();
if !from_words.is_empty() && !to_words.is_empty() {
let join_fns: [fn(&[String]) -> String; 6] = [
join_kebab,
join_snake,
join_upper_snake,
join_pascal,
join_camel,
join_display,
];
let labels = [
"kebab",
"snake_case",
"UPPER_SNAKE",
"PascalCase",
"camelCase",
"Display Name",
];
for (label, join_fn) in labels.iter().zip(join_fns.iter()) {
variants.push(CaseVariant {
from: join_fn(&from_words),
to: join_fn(&to_words),
label: label.to_string(),
});
}
let mut from_words_plural = from_words.clone();
let mut to_words_plural = to_words.clone();
if let Some(last) = from_words_plural.last_mut() {
*last = pluralize(last);
}
if let Some(last) = to_words_plural.last_mut() {
*last = pluralize(last);
}
for (label, join_fn) in labels.iter().zip(join_fns.iter()) {
variants.push(CaseVariant {
from: join_fn(&from_words_plural),
to: join_fn(&to_words_plural),
label: format!("plural {}", label),
});
}
} else {
variants.push(CaseVariant {
from: from.to_lowercase(),
to: to.to_lowercase(),
label: "lowercase".to_string(),
});
}
variants.sort_by(|a, b| b.from.len().cmp(&a.from.len()));
let mut seen = std::collections::HashSet::new();
variants.retain(|v| seen.insert(v.from.clone()));
RenameSpec {
from: from.to_string(),
to: to.to_string(),
scope,
variants,
literal: false,
}
}
pub fn literal(from: &str, to: &str, scope: RenameScope) -> Self {
let variants = vec![CaseVariant {
from: from.to_string(),
to: to.to_string(),
label: "literal".to_string(),
}];
RenameSpec {
from: from.to_string(),
to: to.to_string(),
scope,
variants,
literal: true,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Reference {
pub file: String,
pub line: usize,
pub column: usize,
pub matched: String,
pub replacement: String,
pub variant: String,
pub context: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileEdit {
pub file: String,
pub replacements: usize,
#[serde(skip)]
pub new_content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileRename {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RenameWarning {
pub kind: String,
pub file: String,
pub line: Option<usize>,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RenameResult {
pub variants: Vec<CaseVariant>,
pub references: Vec<Reference>,
pub edits: Vec<FileEdit>,
pub file_renames: Vec<FileRename>,
pub warnings: Vec<RenameWarning>,
pub total_references: usize,
pub total_files: usize,
pub applied: bool,
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn pluralize(s: &str) -> String {
if s.ends_with('s') || s.ends_with('x') || s.ends_with("sh") || s.ends_with("ch") {
format!("{}es", s)
} else if s.ends_with('y') && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("ay") {
format!("{}ies", &s[..s.len() - 1])
} else {
format!("{}s", s)
}
}
fn split_words(term: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let chars: Vec<char> = term.chars().collect();
let len = chars.len();
for i in 0..len {
let c = chars[i];
if c == '-' || c == '_' || c == ' ' || c == '.' {
if !current.is_empty() {
words.push(current.to_lowercase());
current.clear();
}
continue;
}
if c.is_uppercase() && !current.is_empty() {
let prev = chars[i - 1];
let is_camel_boundary = prev.is_lowercase() || prev.is_ascii_digit();
let is_acronym_boundary =
prev.is_uppercase() && i + 1 < len && chars[i + 1].is_lowercase();
if is_camel_boundary || is_acronym_boundary {
words.push(current.to_lowercase());
current.clear();
}
}
current.push(c);
}
if !current.is_empty() {
words.push(current.to_lowercase());
}
words
}
fn join_kebab(words: &[String]) -> String {
words.join("-")
}
fn join_snake(words: &[String]) -> String {
words.join("_")
}
fn join_upper_snake(words: &[String]) -> String {
words
.iter()
.map(|w| w.to_uppercase())
.collect::<Vec<_>>()
.join("_")
}
fn join_pascal(words: &[String]) -> String {
words
.iter()
.map(|w| capitalize(w))
.collect::<Vec<_>>()
.join("")
}
fn join_camel(words: &[String]) -> String {
let mut parts: Vec<String> = Vec::new();
for (i, w) in words.iter().enumerate() {
if i == 0 {
parts.push(w.to_lowercase());
} else {
parts.push(capitalize(w));
}
}
parts.join("")
}
fn join_display(words: &[String]) -> String {
words
.iter()
.map(|w| capitalize(w))
.collect::<Vec<_>>()
.join(" ")
}
fn scan_config_for_scope(scope: &RenameScope) -> ScanConfig {
let extensions = match scope {
RenameScope::Code => ExtensionFilter::Except(vec![
"json".to_string(),
"toml".to_string(),
"yaml".to_string(),
"yml".to_string(),
]),
RenameScope::Config => ExtensionFilter::Only(vec![
"json".to_string(),
"toml".to_string(),
"yaml".to_string(),
"yml".to_string(),
]),
RenameScope::All => ExtensionFilter::SourceDefaults,
};
ScanConfig {
extensions,
..ScanConfig::default()
}
}
pub fn find_references(spec: &RenameSpec, root: &Path) -> Vec<Reference> {
find_references_with_targeting(spec, root, &RenameTargeting::default())
}
pub fn find_references_with_targeting(
spec: &RenameSpec,
root: &Path,
targeting: &RenameTargeting,
) -> Vec<Reference> {
let config = scan_config_for_scope(&spec.scope);
let files = target_files(codebase_scan::walk_files(root, &config), root, targeting);
let mut all_variants = spec.variants.clone();
if !spec.literal {
let mut discovered = Vec::new();
for variant in &spec.variants {
let has_matches = files.iter().any(|f| {
std::fs::read_to_string(f)
.map(|content| {
content
.lines()
.any(|line| !find_boundary_matches(line, &variant.from).is_empty())
})
.unwrap_or(false)
});
if !has_matches {
let casings = discover_casing_in_files(&files, &variant.from, spec.literal);
for (actual_casing, _count) in &casings {
if actual_casing == &variant.from {
continue;
}
if all_variants.iter().any(|v| v.from == *actual_casing) {
continue;
}
discovered.push(CaseVariant {
from: actual_casing.clone(),
to: variant.to.clone(),
label: format!("discovered ({})", variant.label),
});
}
}
}
all_variants.extend(discovered);
}
let mut references = Vec::new();
all_variants.sort_by(|a, b| b.from.len().cmp(&a.from.len()));
let use_literal = spec.literal;
for file_path in &files {
let Ok(content) = std::fs::read_to_string(file_path) else {
continue;
};
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
for (line_num, line) in content.lines().enumerate() {
let mut claimed: Vec<(usize, usize)> = Vec::new();
for variant in &all_variants {
let positions = if use_literal {
find_literal_matches(line, &variant.from)
} else {
find_boundary_matches(line, &variant.from)
};
for pos in positions {
let end = pos + variant.from.len();
if claimed.iter().any(|&(s, e)| pos < e && end > s) {
continue;
}
claimed.push((pos, end));
references.push(Reference {
file: relative.clone(),
line: line_num + 1,
column: pos + 1,
matched: variant.from.clone(),
replacement: variant.to.clone(),
variant: variant.label.clone(),
context: line.to_string(),
});
}
}
}
}
references
}
fn discover_casing_in_files(
files: &[PathBuf],
pattern: &str,
literal: bool,
) -> Vec<(String, usize)> {
let mut counts: HashMap<String, usize> = HashMap::new();
for file in files {
let Ok(content) = std::fs::read_to_string(file) else {
continue;
};
for line in content.lines() {
if literal {
for pos in find_literal_matches(line, pattern) {
let matched = &line[pos..pos + pattern.len()];
*counts.entry(matched.to_string()).or_insert(0) += 1;
}
continue;
}
for (_start, matched) in find_case_insensitive_matches(line, pattern) {
*counts.entry(matched).or_insert(0) += 1;
}
}
}
let mut out: Vec<(String, usize)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
out
}
pub fn generate_renames(spec: &RenameSpec, root: &Path) -> RenameResult {
generate_renames_with_targeting(spec, root, &RenameTargeting::default())
}
pub fn generate_renames_with_targeting(
spec: &RenameSpec,
root: &Path,
targeting: &RenameTargeting,
) -> RenameResult {
let references = find_references_with_targeting(spec, root, targeting);
let config = scan_config_for_scope(&spec.scope);
let files = target_files(codebase_scan::walk_files(root, &config), root, targeting);
let mut sorted_variants = spec.variants.clone();
sorted_variants.sort_by(|a, b| b.from.len().cmp(&a.from.len()));
let mut edits = Vec::new();
let mut affected_files: HashMap<String, bool> = HashMap::new();
let use_literal = spec.literal;
for file_path in &files {
let Ok(content) = std::fs::read_to_string(file_path) else {
continue;
};
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let mut all_matches: Vec<(usize, usize, String)> = Vec::new();
for variant in &sorted_variants {
let positions = if use_literal {
find_literal_matches(&content, &variant.from)
} else {
find_boundary_matches(&content, &variant.from)
};
for pos in positions {
let end = pos + variant.from.len();
if all_matches.iter().any(|&(s, e, _)| pos < e && end > s) {
continue;
}
all_matches.push((pos, end, variant.to.clone()));
}
}
if !all_matches.is_empty() {
let count = all_matches.len();
all_matches.sort_by(|a, b| b.0.cmp(&a.0));
let mut new_content = content;
for (start, end, replacement) in &all_matches {
new_content.replace_range(start..end, replacement);
}
affected_files.insert(relative.clone(), true);
edits.push(FileEdit {
file: relative,
replacements: count,
new_content,
});
}
}
let mut file_renames = Vec::new();
if targeting.rename_files {
for file_path in &files {
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let mut new_relative = relative.clone();
for variant in &sorted_variants {
new_relative = new_relative.replace(&variant.from, &variant.to);
}
if new_relative != relative {
file_renames.push(FileRename {
from: relative,
to: new_relative,
});
}
}
}
file_renames.dedup_by(|a, b| a.from == b.from);
let total_references = references.len();
let total_files = affected_files.len() + file_renames.len();
let warnings = detect_collisions(&edits, &file_renames, root);
RenameResult {
variants: spec.variants.clone(),
references,
edits,
file_renames,
warnings,
total_references,
total_files,
applied: false,
}
}
fn target_files(files: Vec<PathBuf>, root: &Path, targeting: &RenameTargeting) -> Vec<PathBuf> {
files
.into_iter()
.filter(|file| {
let relative = file
.strip_prefix(root)
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/");
if !targeting.include_globs.is_empty()
&& !targeting
.include_globs
.iter()
.any(|glob| glob_match::glob_match(glob, &relative))
{
return false;
}
if targeting
.exclude_globs
.iter()
.any(|glob| glob_match::glob_match(glob, &relative))
{
return false;
}
true
})
.collect()
}
fn detect_collisions(
edits: &[FileEdit],
file_renames: &[FileRename],
root: &Path,
) -> Vec<RenameWarning> {
let mut warnings = Vec::new();
for rename in file_renames {
let target = root.join(&rename.to);
if target.exists() {
warnings.push(RenameWarning {
kind: "file_collision".to_string(),
file: rename.to.clone(),
line: None,
message: format!(
"Rename target '{}' already exists on disk (from '{}')",
rename.to, rename.from
),
});
}
}
for edit in edits {
detect_duplicate_identifiers(&edit.file, &edit.new_content, &mut warnings);
}
warnings
}
fn detect_duplicate_identifiers(file: &str, content: &str, warnings: &mut Vec<RenameWarning>) {
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.ends_with('{') || trimmed.ends_with("{{") {
let block_indent = leading_spaces(lines.get(i + 1).unwrap_or(&""));
if block_indent == 0 {
i += 1;
continue;
}
let mut seen: HashMap<String, usize> = HashMap::new();
let mut j = i + 1;
while j < lines.len() {
let block_line = lines[j];
let block_trimmed = block_line.trim();
if block_trimmed == "}" || block_trimmed == "}," {
break;
}
if leading_spaces(block_line) == block_indent {
if let Some(ident) = extract_field_identifier(block_trimmed) {
if let Some(&first_line) = seen.get(&ident) {
warnings.push(RenameWarning {
kind: "duplicate_identifier".to_string(),
file: file.to_string(),
line: Some(j + 1),
message: format!(
"Duplicate identifier '{}' at line {} (first at line {})",
ident,
j + 1,
first_line
),
});
} else {
seen.insert(ident, j + 1);
}
}
}
j += 1;
}
i = j;
} else {
i += 1;
}
}
}
fn leading_spaces(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn extract_field_identifier(trimmed: &str) -> Option<String> {
if trimmed.starts_with('#')
|| trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.is_empty()
{
return None;
}
let rest = trimmed
.strip_prefix("pub(crate) ")
.or_else(|| trimmed.strip_prefix("pub(super) "))
.or_else(|| trimmed.strip_prefix("pub "))
.unwrap_or(trimmed);
let rest = rest
.strip_prefix("let mut ")
.or_else(|| rest.strip_prefix("let "))
.or_else(|| rest.strip_prefix("fn "))
.or_else(|| rest.strip_prefix("const "))
.or_else(|| rest.strip_prefix("static "))
.unwrap_or(rest);
let ident: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if ident.is_empty() {
return None;
}
let after = &rest[ident.len()..].trim_start();
if after.starts_with(':')
|| after.starts_with('(')
|| after.starts_with('=')
|| after.starts_with('<')
{
Some(ident)
} else {
None
}
}
pub fn apply_renames(result: &mut RenameResult, root: &Path) -> Result<()> {
for edit in &result.edits {
let path = root.join(&edit.file);
std::fs::write(&path, &edit.new_content).map_err(|e| {
Error::internal_io(e.to_string(), Some(format!("write {}", path.display())))
})?;
}
let mut renames = result.file_renames.clone();
renames.sort_by(|a, b| {
b.from
.matches('/')
.count()
.cmp(&a.from.matches('/').count())
});
for rename in &renames {
let from = root.join(&rename.from);
let to = root.join(&rename.to);
if let Some(parent) = to.parent() {
let _ = std::fs::create_dir_all(parent);
}
if from.exists() {
std::fs::rename(&from, &to).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("rename {} → {}", from.display(), to.display())),
)
})?;
}
}
result.applied = true;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capitalize_works() {
assert_eq!(capitalize("widget"), "Widget");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("a"), "A");
}
#[test]
fn pluralize_regular() {
assert_eq!(pluralize("widget"), "widgets");
assert_eq!(pluralize("gadget"), "gadgets");
}
#[test]
fn pluralize_y_ending() {
assert_eq!(pluralize("ability"), "abilities");
assert_eq!(pluralize("query"), "queries");
}
#[test]
fn pluralize_s_ending() {
assert_eq!(pluralize("class"), "classes");
}
#[test]
fn pluralize_preserves_ey_oy_ay() {
assert_eq!(pluralize("key"), "keys");
assert_eq!(pluralize("day"), "days");
}
#[test]
fn rename_spec_generates_variants() {
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let from_values: Vec<&str> = spec.variants.iter().map(|v| v.from.as_str()).collect();
assert!(from_values.contains(&"widget"));
assert!(from_values.contains(&"Widget"));
assert!(from_values.contains(&"WIDGET"));
assert!(from_values.contains(&"widgets"));
assert!(from_values.contains(&"Widgets"));
assert!(from_values.contains(&"WIDGETS"));
let to_values: Vec<&str> = spec.variants.iter().map(|v| v.to.as_str()).collect();
assert!(to_values.contains(&"gadget"));
assert!(to_values.contains(&"Gadget"));
assert!(to_values.contains(&"GADGET"));
assert!(to_values.contains(&"gadgets"));
assert!(to_values.contains(&"Gadgets"));
assert!(to_values.contains(&"GADGETS"));
}
#[test]
fn find_references_in_temp_dir() {
let dir = std::env::temp_dir().join("homeboy_refactor_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("test.rs"),
"pub mod widget;\nuse crate::widget::WidgetManifest;\nconst WIDGET_DIR: &str = \"widgets\";\n",
)
.unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let refs = find_references(&spec, &dir);
assert!(!refs.is_empty());
let matched: Vec<&str> = refs.iter().map(|r| r.matched.as_str()).collect();
assert!(
matched.contains(&"widget"),
"Expected 'widget' in {:?}",
matched
);
assert!(
matched.contains(&"Widget"),
"Expected 'Widget' in {:?}",
matched
);
assert!(
matched.contains(&"WIDGET"),
"Expected 'WIDGET' in {:?}",
matched
);
assert!(
matched.contains(&"widgets"),
"Expected 'widgets' in {:?}",
matched
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generate_renames_produces_edits() {
let dir = std::env::temp_dir().join("homeboy_refactor_gen_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("test.rs"), "pub mod widget;\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let result = generate_renames(&spec, &dir);
assert!(!result.edits.is_empty());
assert_eq!(result.edits[0].new_content, "pub mod gadget;\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generate_renames_detects_file_renames() {
let dir = std::env::temp_dir().join("homeboy_refactor_file_rename_test");
let sub = dir.join("widget");
let _ = std::fs::create_dir_all(&sub);
std::fs::write(sub.join("widget.rs"), "fn widget_init() {}\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let result = generate_renames(&spec, &dir);
assert!(!result.file_renames.is_empty());
let rename = result
.file_renames
.iter()
.find(|r| r.from.contains("widget.rs"))
.unwrap();
assert!(rename.to.contains("gadget.rs"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn word_boundary_no_false_positives() {
let dir = std::env::temp_dir().join("homeboy_refactor_boundary_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("test.rs"), "let widgetry = true;\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let refs = find_references(&spec, &dir);
assert!(
refs.is_empty(),
"Should not match 'widgetry' when renaming 'widget'"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn apply_renames_writes_to_disk() {
let dir = std::env::temp_dir().join("homeboy_refactor_apply_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("test.rs"), "pub mod widget;\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let mut result = generate_renames(&spec, &dir);
apply_renames(&mut result, &dir).unwrap();
assert!(result.applied);
let content = std::fs::read_to_string(dir.join("test.rs")).unwrap();
assert_eq!(content, "pub mod gadget;\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snake_case_compounds_match() {
let matches = find_boundary_matches("load_widget", "widget");
assert_eq!(matches, vec![5], "Should match 'widget' in 'load_widget'");
let matches = find_boundary_matches("is_widget_linked", "widget");
assert_eq!(
matches,
vec![3],
"Should match 'widget' in 'is_widget_linked'"
);
let matches = find_boundary_matches("widget_init", "widget");
assert_eq!(
matches,
vec![0],
"Should match 'widget' at start of 'widget_init'"
);
let matches = find_boundary_matches("WIDGET_DIR", "WIDGET");
assert_eq!(matches, vec![0], "Should match 'WIDGET' in 'WIDGET_DIR'");
let matches = find_boundary_matches("THE_WIDGET_CONFIG", "WIDGET");
assert_eq!(
matches,
vec![4],
"Should match 'WIDGET' in 'THE_WIDGET_CONFIG'"
);
}
#[test]
fn snake_case_rename_in_file() {
let dir = std::env::temp_dir().join("homeboy_refactor_snake_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("test.rs"),
"fn load_widget() {}\nfn is_widget_linked() -> bool { true }\nconst WIDGET_DIR: &str = \"widgets\";\n",
)
.unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let result = generate_renames(&spec, &dir);
assert!(!result.edits.is_empty());
let content = &result.edits[0].new_content;
assert!(
content.contains("load_gadget"),
"Expected 'load_gadget' in:\n{}",
content
);
assert!(
content.contains("is_gadget_linked"),
"Expected 'is_gadget_linked' in:\n{}",
content
);
assert!(
content.contains("GADGET_DIR"),
"Expected 'GADGET_DIR' in:\n{}",
content
);
assert!(
content.contains("\"gadgets\""),
"Expected '\"gadgets\"' in:\n{}",
content
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn node_modules_not_matched() {
let matches = find_boundary_matches("node_modules", "module");
assert!(
matches.is_empty(),
"Should not match 'module' inside 'node_modules' — 's' follows"
);
let matches = find_boundary_matches("node_modules", "modules");
assert_eq!(matches, vec![5], "Should match 'modules' in 'node_modules'");
}
#[test]
fn extract_field_identifier_works() {
assert_eq!(
extract_field_identifier("pub name: String,"),
Some("name".to_string())
);
assert_eq!(
extract_field_identifier("pub(crate) id: u32,"),
Some("id".to_string())
);
assert_eq!(
extract_field_identifier("count: usize,"),
Some("count".to_string())
);
assert_eq!(
extract_field_identifier("let value = 42;"),
Some("value".to_string())
);
assert_eq!(
extract_field_identifier("fn init("),
Some("init".to_string())
);
assert_eq!(extract_field_identifier("// a comment"), None);
assert_eq!(extract_field_identifier("#[serde(skip)]"), None);
assert_eq!(extract_field_identifier(""), None);
}
#[test]
fn detect_duplicate_identifiers_catches_collision() {
let content = "struct Foo {\n pub name: String,\n pub name: u32,\n}\n";
let mut warnings = Vec::new();
detect_duplicate_identifiers("test.rs", content, &mut warnings);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, "duplicate_identifier");
assert!(warnings[0].message.contains("name"));
}
#[test]
fn detect_duplicate_identifiers_no_false_positive() {
let content = "struct Foo {\n pub name: String,\n pub age: u32,\n}\n";
let mut warnings = Vec::new();
detect_duplicate_identifiers("test.rs", content, &mut warnings);
assert!(warnings.is_empty());
}
#[test]
fn collision_detection_file_rename_target_exists() {
let dir = std::env::temp_dir().join("homeboy_collision_file_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("old.rs"), "fn old() {}\n").unwrap();
std::fs::write(dir.join("new.rs"), "fn new() {}\n").unwrap();
let file_renames = vec![FileRename {
from: "old.rs".to_string(),
to: "new.rs".to_string(),
}];
let warnings = detect_collisions(&[], &file_renames, &dir);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, "file_collision");
assert!(warnings[0].message.contains("new.rs"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collision_detection_in_generate_renames() {
let dir = std::env::temp_dir().join("homeboy_collision_gen_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("test.rs"),
"struct Config {\n pub widgets: Vec<String>,\n pub gadgets: Vec<u32>,\n}\n",
)
.unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let result = generate_renames(&spec, &dir);
assert!(
!result.warnings.is_empty(),
"Should detect duplicate 'gadgets' field"
);
assert!(result
.warnings
.iter()
.any(|w| w.kind == "duplicate_identifier"));
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("gadgets")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn nested_build_dir_not_skipped() {
let dir = std::env::temp_dir().join("homeboy_refactor_build_dir_test");
let sub = dir.join("scripts").join("build");
let _ = std::fs::create_dir_all(&sub);
std::fs::write(sub.join("setup.sh"), "WIDGET_PATH=\"$HOME\"\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let refs = find_references(&spec, &dir);
assert!(
!refs.is_empty(),
"Should find 'WIDGET' in scripts/build/setup.sh"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn root_build_dir_still_skipped() {
let dir = std::env::temp_dir().join("homeboy_refactor_root_build_test");
let build_dir = dir.join("build");
let _ = std::fs::create_dir_all(&build_dir);
std::fs::write(build_dir.join("output.rs"), "let widget = true;\n").unwrap();
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let refs = find_references(&spec, &dir);
assert!(
refs.is_empty(),
"Should NOT find refs in root-level build/ dir"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn literal_spec_has_single_variant() {
let spec = RenameSpec::literal(
"datamachine-events",
"data-machine-events",
RenameScope::All,
);
assert!(spec.literal);
assert_eq!(spec.variants.len(), 1);
assert_eq!(spec.variants[0].from, "datamachine-events");
assert_eq!(spec.variants[0].to, "data-machine-events");
assert_eq!(spec.variants[0].label, "literal");
}
#[test]
fn find_literal_matches_exact() {
let matches = find_literal_matches("datamachine-events is great", "datamachine-events");
assert_eq!(matches, vec![0]);
let matches = find_literal_matches("the-datamachine-events-plugin", "datamachine-events");
assert_eq!(matches, vec![4]);
let matches = find_literal_matches(
"datamachine-events and datamachine-events",
"datamachine-events",
);
assert_eq!(matches, vec![0, 23]);
let matches = find_literal_matches("data-machine-events", "datamachine-events");
assert!(matches.is_empty());
}
#[test]
fn literal_mode_finds_references_in_file() {
let dir = std::env::temp_dir().join("homeboy_refactor_literal_refs_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("plugin.php"),
"// Plugin: datamachine-events\ndefine('DATAMACHINE_EVENTS_VERSION', '1.0');\nfunction datamachine_events_init() {}\n",
)
.unwrap();
let spec = RenameSpec::literal(
"datamachine-events",
"data-machine-events",
RenameScope::All,
);
let refs = find_references(&spec, &dir);
assert_eq!(
refs.len(),
1,
"Should find exactly 1 literal match, got: {:?}",
refs.iter().map(|r| &r.matched).collect::<Vec<_>>()
);
assert_eq!(refs[0].matched, "datamachine-events");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn literal_mode_generates_correct_edits() {
let dir = std::env::temp_dir().join("homeboy_refactor_literal_edit_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("plugin.php"),
"Text Domain: datamachine-events\nSlug: datamachine-events\n",
)
.unwrap();
let spec = RenameSpec::literal(
"datamachine-events",
"data-machine-events",
RenameScope::All,
);
let result = generate_renames(&spec, &dir);
assert_eq!(result.edits.len(), 1);
assert_eq!(result.edits[0].replacements, 2);
assert_eq!(
result.edits[0].new_content,
"Text Domain: data-machine-events\nSlug: data-machine-events\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn literal_mode_renames_files() {
let dir = std::env::temp_dir().join("homeboy_refactor_literal_file_rename_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("datamachine-events.php"), "// main file\n").unwrap();
let spec = RenameSpec::literal(
"datamachine-events",
"data-machine-events",
RenameScope::All,
);
let result = generate_renames(&spec, &dir);
assert!(
!result.file_renames.is_empty(),
"Should rename datamachine-events.php"
);
let rename = &result.file_renames[0];
assert_eq!(rename.from, "datamachine-events.php");
assert_eq!(rename.to, "data-machine-events.php");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn literal_mode_apply_writes_to_disk() {
let dir = std::env::temp_dir().join("homeboy_refactor_literal_apply_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("test.php"), "slug: datamachine-events\n").unwrap();
let spec = RenameSpec::literal(
"datamachine-events",
"data-machine-events",
RenameScope::All,
);
let mut result = generate_renames(&spec, &dir);
apply_renames(&mut result, &dir).unwrap();
assert!(result.applied);
let content = std::fs::read_to_string(dir.join("test.php")).unwrap();
assert_eq!(content, "slug: data-machine-events\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn literal_mode_no_boundary_filtering() {
let matches = find_literal_matches("widgetry", "widget");
assert_eq!(
matches,
vec![0],
"Literal should match 'widget' inside 'widgetry'"
);
let boundary_matches = find_boundary_matches("widgetry", "widget");
assert!(
boundary_matches.is_empty(),
"Boundary mode should NOT match 'widget' inside 'widgetry'"
);
}
#[test]
fn split_words_kebab() {
assert_eq!(split_words("wp-agent"), vec!["wp", "agent"]);
assert_eq!(
split_words("data-machine-agent"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_snake() {
assert_eq!(split_words("wp_agent"), vec!["wp", "agent"]);
assert_eq!(
split_words("data_machine_agent"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_upper_snake() {
assert_eq!(split_words("WP_AGENT"), vec!["wp", "agent"]);
assert_eq!(
split_words("DATA_MACHINE_AGENT"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_pascal() {
assert_eq!(split_words("WpAgent"), vec!["wp", "agent"]);
assert_eq!(
split_words("DataMachineAgent"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_consecutive_uppercase() {
assert_eq!(split_words("WPAgent"), vec!["wp", "agent"]);
assert_eq!(split_words("XMLParser"), vec!["xml", "parser"]);
assert_eq!(split_words("HTTPClient"), vec!["http", "client"]);
assert_eq!(split_words("HTTP"), vec!["http"]);
}
#[test]
fn split_words_camel() {
assert_eq!(split_words("wpAgent"), vec!["wp", "agent"]);
assert_eq!(
split_words("dataMachineAgent"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_display() {
assert_eq!(split_words("WP Agent"), vec!["wp", "agent"]);
assert_eq!(
split_words("Data Machine Agent"),
vec!["data", "machine", "agent"]
);
}
#[test]
fn split_words_single() {
assert_eq!(split_words("widget"), vec!["widget"]);
assert_eq!(split_words("Widget"), vec!["widget"]);
assert_eq!(split_words("WIDGET"), vec!["widget"]);
}
#[test]
fn cross_separator_variants_from_kebab() {
let spec = RenameSpec::new("wp-agent", "data-machine-agent", RenameScope::All);
let from_values: Vec<&str> = spec.variants.iter().map(|v| v.from.as_str()).collect();
let to_values: Vec<&str> = spec.variants.iter().map(|v| v.to.as_str()).collect();
assert!(from_values.contains(&"wp-agent"), "Missing kebab from");
assert!(from_values.contains(&"wp_agent"), "Missing snake from");
assert!(
from_values.contains(&"WP_AGENT"),
"Missing UPPER_SNAKE from"
);
assert!(from_values.contains(&"WpAgent"), "Missing PascalCase from");
assert!(from_values.contains(&"wpAgent"), "Missing camelCase from");
assert!(from_values.contains(&"Wp Agent"), "Missing display from");
assert!(
to_values.contains(&"data-machine-agent"),
"Missing kebab to"
);
assert!(
to_values.contains(&"data_machine_agent"),
"Missing snake to"
);
assert!(
to_values.contains(&"DATA_MACHINE_AGENT"),
"Missing UPPER_SNAKE to"
);
assert!(
to_values.contains(&"DataMachineAgent"),
"Missing PascalCase to"
);
assert!(
to_values.contains(&"dataMachineAgent"),
"Missing camelCase to"
);
assert!(
to_values.contains(&"Data Machine Agent"),
"Missing display to"
);
assert!(
from_values.contains(&"wp-agents"),
"Missing plural kebab from"
);
assert!(
from_values.contains(&"wp_agents"),
"Missing plural snake from"
);
assert!(
from_values.contains(&"WP_AGENTS"),
"Missing plural UPPER_SNAKE from"
);
assert!(
from_values.contains(&"WpAgents"),
"Missing plural PascalCase from"
);
}
#[test]
fn cross_separator_variants_from_pascal() {
let spec = RenameSpec::new("WpAgent", "DataMachineAgent", RenameScope::All);
let from_values: Vec<&str> = spec.variants.iter().map(|v| v.from.as_str()).collect();
assert!(from_values.contains(&"wp-agent"), "Missing kebab from");
assert!(from_values.contains(&"wp_agent"), "Missing snake from");
assert!(
from_values.contains(&"WP_AGENT"),
"Missing UPPER_SNAKE from"
);
assert!(from_values.contains(&"WpAgent"), "Missing PascalCase from");
assert!(from_values.contains(&"wpAgent"), "Missing camelCase from");
}
#[test]
fn cross_separator_variants_from_snake() {
let spec = RenameSpec::new("wp_agent", "data_machine_agent", RenameScope::All);
let from_values: Vec<&str> = spec.variants.iter().map(|v| v.from.as_str()).collect();
assert!(from_values.contains(&"wp-agent"), "Missing kebab from");
assert!(from_values.contains(&"wp_agent"), "Missing snake from");
assert!(
from_values.contains(&"WP_AGENT"),
"Missing UPPER_SNAKE from"
);
assert!(from_values.contains(&"WpAgent"), "Missing PascalCase from");
}
#[test]
fn single_word_variants_dedup() {
let spec = RenameSpec::new("widget", "gadget", RenameScope::All);
let from_values: Vec<&str> = spec.variants.iter().map(|v| v.from.as_str()).collect();
assert!(from_values.contains(&"widget"));
assert!(from_values.contains(&"Widget"));
assert!(from_values.contains(&"WIDGET"));
assert!(from_values.contains(&"widgets"));
assert!(from_values.contains(&"Widgets"));
assert!(from_values.contains(&"WIDGETS"));
let mut seen = std::collections::HashSet::new();
for v in &spec.variants {
assert!(seen.insert(&v.from), "Duplicate variant 'from': {}", v.from);
}
}
#[test]
fn boundary_matches_consecutive_uppercase_pascal() {
let matches = find_boundary_matches("WPAgent", "Agent");
assert_eq!(
matches,
vec![2],
"Should match 'Agent' in 'WPAgent' at consecutive-uppercase boundary"
);
let matches = find_boundary_matches("WPAgent", "WP");
assert_eq!(matches, vec![0], "Should match 'WP' at start of 'WPAgent'");
let matches = find_boundary_matches("XMLParser", "XML");
assert_eq!(
matches,
vec![0],
"Should match 'XML' at start of 'XMLParser'"
);
let matches = find_boundary_matches("XMLParser", "Parser");
assert_eq!(matches, vec![3], "Should match 'Parser' in 'XMLParser'");
}
#[test]
fn boundary_matches_wp_agent_display_name() {
let matches = find_boundary_matches("Plugin: WP Agent v1", "WP Agent");
assert_eq!(
matches,
vec![8],
"Should match 'WP Agent' in display context"
);
}
#[test]
fn cross_separator_end_to_end_rename() {
let dir = std::env::temp_dir().join("homeboy_cross_sep_e2e_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("plugin.php"),
concat!(
"// Plugin: wp-agent\n",
"namespace WpAgent;\n",
"define('WP_AGENT_VERSION', '1.0');\n",
"function wp_agent_init() {}\n",
"// slug: wp-agents\n",
),
)
.unwrap();
let spec = RenameSpec::new("wp-agent", "data-machine-agent", RenameScope::All);
let result = generate_renames(&spec, &dir);
assert!(!result.edits.is_empty(), "Should have edits");
let content = &result.edits[0].new_content;
assert!(
content.contains("data-machine-agent"),
"Should rename kebab: {}",
content
);
assert!(
content.contains("DataMachineAgent"),
"Should rename PascalCase: {}",
content
);
assert!(
content.contains("DATA_MACHINE_AGENT_VERSION"),
"Should rename UPPER_SNAKE: {}",
content
);
assert!(
content.contains("data_machine_agent_init"),
"Should rename snake_case: {}",
content
);
assert!(
content.contains("data-machine-agents"),
"Should rename plural kebab: {}",
content
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn include_glob_limits_edits_to_targeted_files() {
let dir = std::env::temp_dir().join("homeboy_refactor_target_include_test");
let _ = std::fs::create_dir_all(dir.join("src"));
let _ = std::fs::create_dir_all(dir.join("tests"));
std::fs::write(dir.join("src/lib.rs"), "fn mark_item_processed() {}\n").unwrap();
std::fs::write(
dir.join("tests/lib_test.rs"),
"fn test_mark_item_processed() {}\n",
)
.unwrap();
let spec = RenameSpec::new(
"mark_item_processed",
"add_processed_item",
RenameScope::All,
);
let targeting = RenameTargeting {
include_globs: vec!["tests/**/*.rs".to_string()],
..RenameTargeting::default()
};
let result = generate_renames_with_targeting(&spec, &dir, &targeting);
assert_eq!(result.edits.len(), 1, "Should only edit tests files");
assert_eq!(result.edits[0].file, "tests/lib_test.rs");
assert!(result.edits[0].new_content.contains("add_processed_item"));
assert!(!result.edits[0].new_content.contains("mark_item_processed"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exclude_glob_omits_matching_files() {
let dir = std::env::temp_dir().join("homeboy_refactor_target_exclude_test");
let _ = std::fs::create_dir_all(dir.join("src"));
let _ = std::fs::create_dir_all(dir.join("tests"));
std::fs::write(dir.join("src/lib.rs"), "fn mark_item_processed() {}\n").unwrap();
std::fs::write(
dir.join("tests/lib_test.rs"),
"fn test_mark_item_processed() {}\n",
)
.unwrap();
let spec = RenameSpec::new(
"mark_item_processed",
"add_processed_item",
RenameScope::All,
);
let targeting = RenameTargeting {
exclude_globs: vec!["src/**/*.rs".to_string()],
..RenameTargeting::default()
};
let result = generate_renames_with_targeting(&spec, &dir, &targeting);
assert_eq!(result.edits.len(), 1, "Should skip excluded src files");
assert_eq!(result.edits[0].file, "tests/lib_test.rs");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_file_renames_suppresses_path_renames() {
let dir = std::env::temp_dir().join("homeboy_refactor_no_file_rename_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("mark_item_processed_test.rs"), "fn ok() {}\n").unwrap();
let spec = RenameSpec::new(
"mark_item_processed",
"add_processed_item",
RenameScope::All,
);
let targeting = RenameTargeting {
rename_files: false,
..RenameTargeting::default()
};
let result = generate_renames_with_targeting(&spec, &dir, &targeting);
assert!(
result.file_renames.is_empty(),
"File renames should be disabled when rename_files=false"
);
let _ = std::fs::remove_dir_all(&dir);
}
}