use glob_match::glob_match;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::engine::codebase_scan::{self, ExtensionFilter, ScanConfig};
use crate::engine::local_files;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformSet {
#[serde(default)]
pub description: String,
pub rules: Vec<TransformRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformRule {
pub id: String,
#[serde(default)]
pub description: String,
pub find: String,
pub replace: String,
#[serde(default = "default_files_glob")]
pub files: String,
#[serde(default = "default_context")]
pub context: String,
}
fn default_files_glob() -> String {
"**/*".to_string()
}
fn default_context() -> String {
"line".to_string()
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformResult {
pub name: String,
pub rules: Vec<RuleResult>,
pub total_replacements: usize,
pub total_files: usize,
pub written: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RuleResult {
pub id: String,
pub description: String,
pub matches: Vec<TransformMatch>,
pub replacement_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformMatch {
pub file: String,
pub line: usize,
pub before: String,
pub after: String,
}
pub(crate) fn unescape_replacement_template(template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut chars = template.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('0') => out.push('\0'),
Some('"') => out.push('"'),
Some('\'') => out.push('\''),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
pub fn ad_hoc_transform(find: &str, replace: &str, files: &str, context: &str) -> TransformSet {
TransformSet {
description: "Ad-hoc transform".to_string(),
rules: vec![TransformRule {
id: "ad-hoc".to_string(),
description: String::new(),
find: find.to_string(),
replace: replace.to_string(),
files: files.to_string(),
context: context.to_string(),
}],
}
}
pub fn apply_transforms(
root: &Path,
name: &str,
set: &TransformSet,
write: bool,
rule_filter: Option<&str>,
) -> Result<TransformResult> {
let compiled_rules: Vec<(&TransformRule, Regex)> = set
.rules
.iter()
.filter(|r| rule_filter.is_none_or(|f| r.id == f))
.map(|r| {
let regex = Regex::new(&r.find).map_err(|e| {
Error::internal_io(
format!("Invalid regex in rule '{}': {}", r.id, e),
Some("transform.apply".to_string()),
)
})?;
Ok((r, regex))
})
.collect::<Result<Vec<_>>>()?;
if compiled_rules.is_empty() {
if let Some(filter) = rule_filter {
let available: Vec<&str> = set.rules.iter().map(|r| r.id.as_str()).collect();
return Err(Error::internal_io(
format!(
"Rule '{}' not found in transform set '{}'. Available: {:?}",
filter, name, available
),
Some("transform.apply".to_string()),
));
}
}
let files = codebase_scan::walk_files(
root,
&ScanConfig {
extensions: ExtensionFilter::All,
..Default::default()
},
);
let mut rule_results = Vec::new();
let mut file_edits: HashMap<PathBuf, String> = HashMap::new();
for (rule, regex) in &compiled_rules {
let matching_files: Vec<&PathBuf> = files
.iter()
.filter(|f| {
let rel = f.strip_prefix(root).unwrap_or(f);
let rel_str = rel.to_string_lossy();
let normalized = rel_str.replace('\\', "/");
glob_match(&rule.files, &normalized)
})
.collect();
let mut matches = Vec::new();
let replace_unescaped = unescape_replacement_template(&rule.replace);
for file_path in matching_files {
let content = if let Some(edited) = file_edits.get(file_path) {
edited.clone()
} else {
match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
}
};
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let (new_content, file_matches) = if rule.context == "file" {
apply_file_context(regex, &replace_unescaped, &content, &relative)
} else if rule.context == "hoist_static" {
apply_hoist_static_context(regex, &replace_unescaped, &content, &relative)
} else {
apply_line_context(regex, &replace_unescaped, &content, &relative)
};
if !file_matches.is_empty() {
matches.extend(file_matches);
file_edits.insert(file_path.clone(), new_content);
}
}
let replacement_count = matches.len();
rule_results.push(RuleResult {
id: rule.id.clone(),
description: rule.description.clone(),
matches,
replacement_count,
});
}
let total_replacements: usize = rule_results.iter().map(|r| r.replacement_count).sum();
let total_files = file_edits.len();
if write && !file_edits.is_empty() {
for (path, content) in &file_edits {
local_files::write_file(path, content, "write transformed file")?;
}
}
Ok(TransformResult {
name: name.to_string(),
rules: rule_results,
total_replacements,
total_files,
written: write,
})
}
const CASE_TRANSFORM_PATTERN: &str = r"\$(?:(\d+)|([a-zA-Z_]\w*)|\{([a-zA-Z_]\w*)\}):(\w+)";
fn has_case_transforms(replace: &str) -> bool {
lazy_static_regex(CASE_TRANSFORM_PATTERN).is_match(replace)
}
fn lazy_static_regex(pattern: &str) -> Regex {
Regex::new(pattern).expect("internal regex should be valid")
}
fn apply_case_transform(input: &str, transform: &str) -> Option<String> {
match transform {
"lower" => Some(input.to_lowercase()),
"upper" => Some(input.to_uppercase()),
"kebab" => Some(to_kebab_case(input)),
"snake" => Some(to_snake_case(input)),
"pascal" => Some(to_pascal_case(input)),
"camel" => Some(to_camel_case(input)),
_ => None,
}
}
fn expand_with_case_transforms(template: &str, caps: ®ex::Captures) -> String {
let case_re = lazy_static_regex(CASE_TRANSFORM_PATTERN);
let intermediate = case_re
.replace_all(template, |m: ®ex::Captures| {
let transform = &m[4];
let value = if let Some(num) = m.get(1) {
let idx: usize = num.as_str().parse().unwrap_or(0);
caps.get(idx).map(|c| c.as_str().to_string())
} else if let Some(name) = m.get(2) {
caps.name(name.as_str()).map(|c| c.as_str().to_string())
} else if let Some(name) = m.get(3) {
caps.name(name.as_str()).map(|c| c.as_str().to_string())
} else {
None
};
match value {
Some(val) => apply_case_transform(&val, transform).unwrap_or(val),
None => String::new(),
}
})
.to_string();
expand_standard_refs(&intermediate, caps)
}
fn expand_standard_refs(template: &str, caps: ®ex::Captures) -> String {
let ref_re = lazy_static_regex(r"\$\$|\$(\d+)|\$\{([a-zA-Z_]\w*)\}");
ref_re
.replace_all(template, |m: ®ex::Captures| {
let full = m.get(0).unwrap().as_str();
if full == "$$" {
return "$".to_string();
}
if let Some(num) = m.get(1) {
let idx: usize = num.as_str().parse().unwrap_or(0);
return caps
.get(idx)
.map(|c| c.as_str().to_string())
.unwrap_or_default();
}
if let Some(name) = m.get(2) {
return caps
.name(name.as_str())
.map(|c| c.as_str().to_string())
.unwrap_or_default();
}
String::new()
})
.to_string()
}
fn split_into_words(input: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let chars: Vec<char> = input.chars().collect();
for i in 0..chars.len() {
let c = chars[i];
if c == '_' || c == '-' || c == ' ' {
if !current.is_empty() {
words.push(current.clone());
current.clear();
}
continue;
}
if c.is_uppercase() && !current.is_empty() {
let last = current.chars().last().unwrap();
if last.is_lowercase() || last.is_ascii_digit() {
words.push(current.clone());
current.clear();
}
else if last.is_uppercase()
&& i + 1 < chars.len()
&& chars[i + 1].is_lowercase()
&& current.len() > 1
{
let last_char = current.pop().unwrap();
if !current.is_empty() {
words.push(current.clone());
}
current.clear();
current.push(last_char);
}
}
current.push(c);
}
if !current.is_empty() {
words.push(current);
}
words
}
fn to_kebab_case(input: &str) -> String {
split_into_words(input)
.iter()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("-")
}
fn to_snake_case(input: &str) -> String {
split_into_words(input)
.iter()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("_")
}
fn to_pascal_case(input: &str) -> String {
split_into_words(input)
.iter()
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(c) => {
let upper: String = c.to_uppercase().collect();
upper + &chars.as_str().to_lowercase()
}
None => String::new(),
}
})
.collect()
}
fn to_camel_case(input: &str) -> String {
let pascal = to_pascal_case(input);
let mut chars = pascal.chars();
match chars.next() {
Some(c) => {
let lower: String = c.to_lowercase().collect();
lower + chars.as_str()
}
None => String::new(),
}
}
fn apply_line_context(
regex: &Regex,
replace: &str,
content: &str,
relative_path: &str,
) -> (String, Vec<TransformMatch>) {
let mut matches = Vec::new();
let mut new_lines = Vec::new();
let use_case_transforms = has_case_transforms(replace);
for (i, line) in content.lines().enumerate() {
if regex.is_match(line) {
let replaced = if use_case_transforms {
replace_with_case_transforms(regex, replace, line)
} else {
regex.replace_all(line, replace).to_string()
};
if replaced != line {
matches.push(TransformMatch {
file: relative_path.to_string(),
line: i + 1,
before: line.to_string(),
after: replaced.clone(),
});
new_lines.push(replaced);
continue;
}
}
new_lines.push(line.to_string());
}
let mut result = new_lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
(result, matches)
}
fn apply_file_context(
regex: &Regex,
replace: &str,
content: &str,
relative_path: &str,
) -> (String, Vec<TransformMatch>) {
let mut matches = Vec::new();
let use_case_transforms = has_case_transforms(replace);
for cap in regex.captures_iter(content) {
let full_match = cap.get(0).unwrap();
let before_text = &content[..full_match.start()];
let line_num = before_text.chars().filter(|&c| c == '\n').count() + 1;
let matched = full_match.as_str().to_string();
let replaced = if use_case_transforms {
expand_with_case_transforms(replace, &cap)
} else {
regex.replace(full_match.as_str(), replace).to_string()
};
if matched != replaced {
matches.push(TransformMatch {
file: relative_path.to_string(),
line: line_num,
before: matched,
after: replaced,
});
}
}
let new_content = if use_case_transforms {
replace_with_case_transforms(regex, replace, content)
} else {
regex.replace_all(content, replace).to_string()
};
(new_content, matches)
}
fn apply_hoist_static_context(
regex: &Regex,
replace: &str,
content: &str,
relative_path: &str,
) -> (String, Vec<TransformMatch>) {
let lines: Vec<&str> = content.lines().collect();
let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
let mut matches = Vec::new();
let mut match_sites: Vec<(usize, String, String, String)> = Vec::new();
let test_mod_start = lines.iter().position(|l| l.trim() == "#[cfg(test)]");
for (i, line) in lines.iter().enumerate() {
if let Some(test_start) = test_mod_start {
if i >= test_start {
continue;
}
}
if let Some(caps) = regex.captures(line) {
let mut var_name = None;
let mut init_expr = None;
for g in 1..=caps.len().saturating_sub(1) {
if let Some(m) = caps.get(g) {
let text = m.as_str().trim();
if text.is_empty() || text.starts_with("mut") {
continue;
}
if var_name.is_none()
&& text.len() < 50
&& text.chars().all(|c| c.is_alphanumeric() || c == '_')
{
var_name = Some(text.to_string());
} else if init_expr.is_none() {
init_expr = Some(text.to_string());
}
}
}
let var = match var_name {
Some(v) => v,
None => continue,
};
let screaming = to_snake_case(&var).to_uppercase();
let indent = &line[..line.len() - line.trim_start().len()];
let replaced = replace
.replace("$1", &screaming)
.replace("$2", init_expr.as_deref().unwrap_or(""))
.split('\n')
.enumerate()
.map(|(j, part)| {
if j == 0 {
format!("{}{}", indent, part)
} else {
format!("{}{}", indent, part)
}
})
.collect::<Vec<_>>()
.join("\n");
matches.push(TransformMatch {
file: relative_path.to_string(),
line: i + 1,
before: line.to_string(),
after: replaced.clone(),
});
new_lines[i] = replaced;
match_sites.push((i, var, screaming, line.to_string()));
}
}
for (match_line, old_var, new_var, _) in &match_sites {
if old_var == new_var {
continue;
}
let fn_start = find_enclosing_fn_start(&new_lines, *match_line);
let fn_end = find_enclosing_fn_end(&new_lines, fn_start.unwrap_or(0));
let start = fn_start.unwrap_or(0);
let end = fn_end.unwrap_or(new_lines.len());
let var_re = match Regex::new(&format!(r"\b{}\b", regex::escape(old_var))) {
Ok(r) => r,
Err(_) => continue,
};
for i in start..end {
if i == *match_line {
continue; }
if var_re.is_match(&new_lines[i]) {
let renamed = var_re.replace_all(&new_lines[i], new_var.as_str());
if renamed != new_lines[i] {
matches.push(TransformMatch {
file: relative_path.to_string(),
line: i + 1,
before: new_lines[i].clone(),
after: renamed.to_string(),
});
new_lines[i] = renamed.to_string();
}
}
}
}
let mut result = new_lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
(result, matches)
}
fn find_enclosing_fn_start(lines: &[String], from: usize) -> Option<usize> {
static FN_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+\w+").unwrap()
});
(0..=from).rev().find(|&i| FN_RE.is_match(&lines[i]))
}
fn find_enclosing_fn_end(lines: &[String], fn_line: usize) -> Option<usize> {
let mut depth: i32 = 0;
let mut found_open = false;
for i in fn_line..lines.len() {
for ch in lines[i].chars() {
if ch == '{' {
depth += 1;
found_open = true;
} else if ch == '}' {
depth -= 1;
if found_open && depth == 0 {
return Some(i + 1);
}
}
}
}
None
}
fn replace_with_case_transforms(regex: &Regex, replace: &str, text: &str) -> String {
regex
.replace_all(text, |caps: ®ex::Captures| {
expand_with_case_transforms(replace, caps)
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unescape_collapses_double_backslash_to_one() {
assert_eq!(unescape_replacement_template("\\\\X"), "\\X");
}
#[test]
fn unescape_preserves_single_literal_backslash_escape_in_php_namespace() {
let input = "\\\\WP_Abilities_Registry::get_instance()";
let expected = "\\WP_Abilities_Registry::get_instance()";
assert_eq!(unescape_replacement_template(input), expected);
}
#[test]
fn unescape_handles_control_sequences() {
assert_eq!(unescape_replacement_template("a\\nb"), "a\nb");
assert_eq!(unescape_replacement_template("a\\tb"), "a\tb");
assert_eq!(unescape_replacement_template("a\\rb"), "a\rb");
assert_eq!(unescape_replacement_template("a\\0b"), "a\0b");
}
#[test]
fn unescape_handles_quote_escapes() {
assert_eq!(unescape_replacement_template("\\\""), "\"");
assert_eq!(unescape_replacement_template("\\'"), "'");
}
#[test]
fn unescape_leaves_dollar_captures_alone() {
assert_eq!(unescape_replacement_template("$1"), "$1");
assert_eq!(unescape_replacement_template("$$"), "$$");
assert_eq!(unescape_replacement_template("${name}"), "${name}");
}
#[test]
fn unescape_preserves_unknown_escape_sequences() {
assert_eq!(unescape_replacement_template("\\q"), "\\q");
assert_eq!(unescape_replacement_template("\\!"), "\\!");
}
#[test]
fn unescape_handles_trailing_backslash() {
assert_eq!(unescape_replacement_template("abc\\"), "abc\\");
}
#[test]
fn unescape_is_idempotent_over_single_backslash_runs() {
assert_eq!(unescape_replacement_template("\\\\\\\\"), "\\\\");
}
#[test]
fn end_to_end_php_namespace_ternary_replacement() {
let find = r"class_exists\( 'WP_Abilities_Registry' \) \? \\WP_Abilities_Registry::get_instance\(\) : null";
let replace_in_memory = "\\\\WP_Abilities_Registry::get_instance()";
let content =
"$r = class_exists( 'WP_Abilities_Registry' ) ? \\WP_Abilities_Registry::get_instance() : null;";
let regex = Regex::new(find).unwrap();
let unescaped = unescape_replacement_template(replace_in_memory);
let (out, matches) = apply_line_context(®ex, &unescaped, content, "t.php");
assert_eq!(matches.len(), 1);
assert!(
out.contains("= \\WP_Abilities_Registry::get_instance()"),
"expected single backslash PHP FQN, got: {out:?}"
);
assert!(
!out.contains("\\\\WP_Abilities_Registry"),
"should not emit double backslashes: {out:?}"
);
}
#[test]
fn deserialize_transform_set() {
let json = r#"{
"description": "Test migration",
"rules": [
{
"id": "fix_code",
"find": "old_function",
"replace": "new_function",
"files": "**/*.php"
}
]
}"#;
let set: TransformSet = serde_json::from_str(json).unwrap();
assert_eq!(set.rules.len(), 1);
assert_eq!(set.rules[0].id, "fix_code");
assert_eq!(set.rules[0].context, "line"); }
#[test]
fn deserialize_rule_defaults() {
let json = r#"{"id": "x", "find": "a", "replace": "b"}"#;
let rule: TransformRule = serde_json::from_str(json).unwrap();
assert_eq!(rule.files, "**/*");
assert_eq!(rule.context, "line");
assert_eq!(rule.description, "");
}
#[test]
fn line_context_simple_replace() {
let regex = Regex::new("rest_forbidden").unwrap();
let content = "if ($code === 'rest_forbidden') {\n return false;\n}\n";
let (new, matches) =
apply_line_context(®ex, "ability_invalid_permissions", content, "test.php");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].line, 1);
assert_eq!(matches[0].before, "if ($code === 'rest_forbidden') {");
assert_eq!(
matches[0].after,
"if ($code === 'ability_invalid_permissions') {"
);
assert!(new.contains("ability_invalid_permissions"));
assert!(!new.contains("rest_forbidden"));
}
#[test]
fn line_context_with_capture_groups() {
let regex = Regex::new(r"\$this->assertIsArray\((.+?)\)").unwrap();
let content = "$this->assertIsArray($result);\n$this->assertIsArray($other);\n";
let (new, matches) = apply_line_context(
®ex,
"$$this->assertInstanceOf(WP_Error::class, $1)",
content,
"test.php",
);
assert_eq!(matches.len(), 2);
assert!(new.contains("assertInstanceOf(WP_Error::class, $result)"));
assert!(new.contains("assertInstanceOf(WP_Error::class, $other)"));
}
#[test]
fn line_context_no_match_unchanged() {
let regex = Regex::new("xyz_not_found").unwrap();
let content = "some normal code\nmore code\n";
let (new, matches) = apply_line_context(®ex, "replaced", content, "test.php");
assert!(matches.is_empty());
assert_eq!(new, content);
}
#[test]
fn line_context_preserves_trailing_newline() {
let regex = Regex::new("old").unwrap();
let content = "old\n";
let (new, _) = apply_line_context(®ex, "new", content, "f.txt");
assert!(new.ends_with('\n'));
assert_eq!(new, "new\n");
}
#[test]
fn line_context_no_trailing_newline() {
let regex = Regex::new("old").unwrap();
let content = "old";
let (new, _) = apply_line_context(®ex, "new", content, "f.txt");
assert!(!new.ends_with('\n'));
assert_eq!(new, "new");
}
#[test]
fn file_context_multiline_match() {
let regex = Regex::new(r"(?s)function\s+old_name\(\).*?\}").unwrap();
let content = "function old_name() {\n return 1;\n}\n";
let (new, matches) = apply_file_context(
®ex,
"function new_name() {\n return 2;\n}",
content,
"test.php",
);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].line, 1);
assert!(new.contains("new_name"));
}
#[test]
fn case_transform_kebab() {
assert_eq!(to_kebab_case("BlueskyDelete"), "bluesky-delete");
assert_eq!(to_kebab_case("FacebookPost"), "facebook-post");
assert_eq!(to_kebab_case("simple"), "simple");
}
#[test]
fn case_transform_snake() {
assert_eq!(to_snake_case("BlueskyDelete"), "bluesky_delete");
assert_eq!(to_snake_case("camelCase"), "camel_case");
}
#[test]
fn case_transform_pascal() {
assert_eq!(to_pascal_case("bluesky-delete"), "BlueskyDelete");
assert_eq!(to_pascal_case("some_snake"), "SomeSnake");
assert_eq!(to_pascal_case("already"), "Already");
}
#[test]
fn case_transform_camel() {
assert_eq!(to_camel_case("BlueskyDelete"), "blueskyDelete");
assert_eq!(to_camel_case("some-kebab"), "someKebab");
}
#[test]
fn case_transform_upper_lower() {
assert_eq!(
apply_case_transform("Hello", "upper"),
Some("HELLO".to_string())
);
assert_eq!(
apply_case_transform("Hello", "lower"),
Some("hello".to_string())
);
}
#[test]
fn line_context_with_case_transform() {
let regex = Regex::new(r"new (\w+)Ability\(\)").unwrap();
let content = "let x = new BlueskyDeleteAbility();\nlet y = new FacebookPostAbility();\n";
let (new, matches) = apply_line_context(
®ex,
"wp_get_ability('datamachine/$1:kebab')",
content,
"test.rs",
);
assert_eq!(matches.len(), 2);
assert!(
new.contains("wp_get_ability('datamachine/bluesky-delete')"),
"got: {}",
new
);
assert!(
new.contains("wp_get_ability('datamachine/facebook-post')"),
"got: {}",
new
);
}
#[test]
fn case_transform_with_literal_dollar() {
let regex = Regex::new(r"new (\w+)Ability\(\)").unwrap();
let content = "$ability = new BlueskyDeleteAbility();\n";
let (new, _) = apply_line_context(
®ex,
"$$ability = wp_get_ability('datamachine/$1:kebab')",
content,
"test.php",
);
assert!(
new.contains("$ability = wp_get_ability('datamachine/bluesky-delete')"),
"got: {}",
new
);
}
#[test]
fn case_transform_mixed_with_plain_refs() {
let regex = Regex::new(r"(\w+)::(\w+)").unwrap();
let content = "BlueskyApi::PostMessage\n";
let (new, _) = apply_line_context(
®ex,
"$1:snake::$2:kebab (was $1::$2)",
content,
"test.rs",
);
assert!(
new.contains("bluesky_api::post-message (was BlueskyApi::PostMessage)"),
"got: {}",
new
);
}
#[test]
fn has_case_transforms_detection() {
assert!(has_case_transforms("$1:kebab"));
assert!(has_case_transforms("prefix $2:upper suffix"));
assert!(has_case_transforms("${name}:snake"));
assert!(!has_case_transforms("$1 plain"));
assert!(!has_case_transforms("no refs here"));
assert!(has_case_transforms("$$1:kebab"));
}
#[test]
fn glob_matches_php_test_files() {
assert!(glob_match("tests/**/*.php", "tests/Unit/FooTest.php"));
assert!(glob_match("tests/**/*.php", "tests/FooTest.php"));
assert!(!glob_match("tests/**/*.php", "src/Foo.php"));
}
#[test]
fn glob_matches_all_files() {
assert!(glob_match("**/*", "any/path/file.rs"));
assert!(glob_match("**/*.php", "deep/nested/path/file.php"));
}
#[test]
fn ad_hoc_transform_builds_single_rule_set() {
let set = ad_hoc_transform("old", "new", "**/*.php", "file");
assert_eq!(set.description, "Ad-hoc transform");
assert_eq!(set.rules.len(), 1);
assert_eq!(set.rules[0].id, "ad-hoc");
assert_eq!(set.rules[0].find, "old");
assert_eq!(set.rules[0].replace, "new");
assert_eq!(set.rules[0].files, "**/*.php");
assert_eq!(set.rules[0].context, "file");
}
}