1use regex::Regex;
4use std::fs;
5use std::path::Path;
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone)]
10pub struct ReplacePattern {
11 pub find: String,
13 pub replace: String,
15}
16
17#[derive(Debug, Clone)]
19pub struct ReplaceOptions {
20 pub patterns: Vec<ReplacePattern>,
22 pub file_extensions: Vec<String>,
24 pub recursive: bool,
26 pub dry_run: bool,
28}
29
30impl Default for ReplaceOptions {
31 fn default() -> Self {
32 ReplaceOptions {
33 patterns: Vec::new(),
34 file_extensions: vec![
35 ".py", ".pyx", ".pxd", ".pxi", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".java",
36 ".js", ".ts", ".jsx", ".tsx", ".md", ".qmd", ".txt", ".toml", ".yaml", ".yml",
37 ".json", ".xml", ".html", ".css", ".sh",
38 ]
39 .iter()
40 .map(|s| s.to_string())
41 .collect(),
42 recursive: true,
43 dry_run: false,
44 }
45 }
46}
47
48#[derive(Debug)]
50struct CompiledPattern {
51 regex: Regex,
52 replace: String,
53}
54
55#[derive(Debug)]
57pub struct ContentReplacer {
58 options: ReplaceOptions,
59 compiled: Vec<CompiledPattern>,
60}
61
62impl ContentReplacer {
63 pub fn new(options: ReplaceOptions) -> crate::Result<Self> {
66 let mut compiled = Vec::with_capacity(options.patterns.len());
67 for pattern in &options.patterns {
68 let regex = Regex::new(&pattern.find)
69 .map_err(|e| anyhow::anyhow!("invalid regex pattern '{}': {}", pattern.find, e))?;
70 compiled.push(CompiledPattern {
71 regex,
72 replace: pattern.replace.clone(),
73 });
74 }
75 Ok(ContentReplacer { options, compiled })
76 }
77
78 fn should_process(&self, path: &Path) -> bool {
80 if !path.is_file() {
81 return false;
82 }
83
84 if path.components().any(|c| {
85 c.as_os_str()
86 .to_str()
87 .map(|s| s.starts_with('.'))
88 .unwrap_or(false)
89 }) {
90 return false;
91 }
92
93 let skip_dirs = [
94 "build",
95 "__pycache__",
96 ".git",
97 "node_modules",
98 "venv",
99 ".venv",
100 "target",
101 ];
102 if path.components().any(|c| {
103 c.as_os_str()
104 .to_str()
105 .map(|s| skip_dirs.contains(&s))
106 .unwrap_or(false)
107 }) {
108 return false;
109 }
110
111 if let Some(ext) = path.extension() {
112 let ext_str = format!(".{}", ext.to_string_lossy());
113 self.options.file_extensions.contains(&ext_str)
114 } else {
115 false
116 }
117 }
118
119 pub fn replace_file(&self, path: &Path) -> crate::Result<usize> {
121 if !self.should_process(path) {
122 return Ok(0);
123 }
124
125 if self.compiled.is_empty() {
126 return Ok(0);
127 }
128
129 let content = fs::read_to_string(path)?;
130 let mut current = content.clone();
131 let mut total_replacements = 0;
132
133 for cp in &self.compiled {
134 let result = cp.regex.replace_all(¤t, cp.replace.as_str());
135 if result != current {
136 let count = cp.regex.find_iter(¤t).count();
138 total_replacements += count;
139 current = result.into_owned();
140 }
141 }
142
143 if total_replacements > 0 {
144 if self.options.dry_run {
145 println!(
146 "Would make {} replacement(s) in '{}'",
147 total_replacements,
148 path.display()
149 );
150 } else {
151 fs::write(path, ¤t)?;
152 println!(
153 "Made {} replacement(s) in '{}'",
154 total_replacements,
155 path.display()
156 );
157 }
158 }
159
160 Ok(total_replacements)
161 }
162
163 pub fn process(&self, path: &Path) -> crate::Result<(usize, usize)> {
165 let mut total_files = 0;
166 let mut total_replacements = 0;
167
168 if path.is_file() {
169 let replacements = self.replace_file(path)?;
170 if replacements > 0 {
171 total_files = 1;
172 total_replacements = replacements;
173 }
174 } else if path.is_dir() {
175 if self.options.recursive {
176 for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
177 if entry.file_type().is_file() {
178 let replacements = self.replace_file(entry.path())?;
179 if replacements > 0 {
180 total_files += 1;
181 total_replacements += replacements;
182 }
183 }
184 }
185 } else {
186 for entry in fs::read_dir(path)? {
187 let entry = entry?;
188 let entry_path = entry.path();
189 if entry_path.is_file() {
190 let replacements = self.replace_file(&entry_path)?;
191 if replacements > 0 {
192 total_files += 1;
193 total_replacements += replacements;
194 }
195 }
196 }
197 }
198 }
199
200 Ok((total_files, total_replacements))
201 }
202}
203
204#[derive(Debug, Clone, serde::Deserialize)]
206pub struct ReplacePatternConfig {
207 pub find: String,
208 pub replace: String,
209}
210
211impl From<ReplacePatternConfig> for ReplacePattern {
212 fn from(cfg: ReplacePatternConfig) -> Self {
213 ReplacePattern {
214 find: cfg.find,
215 replace: cfg.replace,
216 }
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::fs;
224
225 #[test]
226 fn test_simple_replacement() {
227 let dir = std::env::temp_dir().join("reformat_replace_simple");
228 fs::create_dir_all(&dir).unwrap();
229
230 let file = dir.join("test.txt");
231 fs::write(&file, "hello world\nhello rust\n").unwrap();
232
233 let options = ReplaceOptions {
234 patterns: vec![ReplacePattern {
235 find: "hello".to_string(),
236 replace: "greetings".to_string(),
237 }],
238 ..Default::default()
239 };
240 let replacer = ContentReplacer::new(options).unwrap();
241 let (files, replacements) = replacer.process(&file).unwrap();
242
243 assert_eq!(files, 1);
244 assert_eq!(replacements, 2);
245
246 let content = fs::read_to_string(&file).unwrap();
247 assert_eq!(content, "greetings world\ngreetings rust\n");
248
249 fs::remove_dir_all(&dir).unwrap();
250 }
251
252 #[test]
253 fn test_regex_pattern() {
254 let dir = std::env::temp_dir().join("reformat_replace_regex");
255 fs::create_dir_all(&dir).unwrap();
256
257 let file = dir.join("test.txt");
258 fs::write(&file, "foo123 bar456 baz\n").unwrap();
259
260 let options = ReplaceOptions {
261 patterns: vec![ReplacePattern {
262 find: r"[a-z]+(\d+)".to_string(),
263 replace: "num_$1".to_string(),
264 }],
265 ..Default::default()
266 };
267 let replacer = ContentReplacer::new(options).unwrap();
268 let (files, replacements) = replacer.process(&file).unwrap();
269
270 assert_eq!(files, 1);
271 assert_eq!(replacements, 2);
272
273 let content = fs::read_to_string(&file).unwrap();
274 assert_eq!(content, "num_123 num_456 baz\n");
275
276 fs::remove_dir_all(&dir).unwrap();
277 }
278
279 #[test]
280 fn test_multiple_patterns_sequential() {
281 let dir = std::env::temp_dir().join("reformat_replace_multi");
282 fs::create_dir_all(&dir).unwrap();
283
284 let file = dir.join("test.txt");
285 fs::write(&file, "Copyright 2024 OldCorp\n").unwrap();
286
287 let options = ReplaceOptions {
288 patterns: vec![
289 ReplacePattern {
290 find: "2024".to_string(),
291 replace: "2025".to_string(),
292 },
293 ReplacePattern {
294 find: "OldCorp".to_string(),
295 replace: "NewCorp".to_string(),
296 },
297 ],
298 ..Default::default()
299 };
300 let replacer = ContentReplacer::new(options).unwrap();
301 replacer.process(&file).unwrap();
302
303 let content = fs::read_to_string(&file).unwrap();
304 assert_eq!(content, "Copyright 2025 NewCorp\n");
305
306 fs::remove_dir_all(&dir).unwrap();
307 }
308
309 #[test]
310 fn test_no_matches() {
311 let dir = std::env::temp_dir().join("reformat_replace_none");
312 fs::create_dir_all(&dir).unwrap();
313
314 let file = dir.join("test.txt");
315 fs::write(&file, "nothing to change\n").unwrap();
316
317 let options = ReplaceOptions {
318 patterns: vec![ReplacePattern {
319 find: "xyz".to_string(),
320 replace: "abc".to_string(),
321 }],
322 ..Default::default()
323 };
324 let replacer = ContentReplacer::new(options).unwrap();
325 let (files, replacements) = replacer.process(&file).unwrap();
326
327 assert_eq!(files, 0);
328 assert_eq!(replacements, 0);
329
330 fs::remove_dir_all(&dir).unwrap();
331 }
332
333 #[test]
334 fn test_invalid_regex() {
335 let options = ReplaceOptions {
336 patterns: vec![ReplacePattern {
337 find: "[invalid".to_string(),
338 replace: "x".to_string(),
339 }],
340 ..Default::default()
341 };
342 let result = ContentReplacer::new(options);
343 assert!(result.is_err());
344 assert!(result.unwrap_err().to_string().contains("invalid regex"));
345 }
346
347 #[test]
348 fn test_dry_run() {
349 let dir = std::env::temp_dir().join("reformat_replace_dry");
350 fs::create_dir_all(&dir).unwrap();
351
352 let file = dir.join("test.txt");
353 let original = "hello world\n";
354 fs::write(&file, original).unwrap();
355
356 let options = ReplaceOptions {
357 patterns: vec![ReplacePattern {
358 find: "hello".to_string(),
359 replace: "bye".to_string(),
360 }],
361 dry_run: true,
362 ..Default::default()
363 };
364 let replacer = ContentReplacer::new(options).unwrap();
365 let (_, replacements) = replacer.process(&file).unwrap();
366
367 assert_eq!(replacements, 1);
368 let content = fs::read_to_string(&file).unwrap();
369 assert_eq!(content, original);
370
371 fs::remove_dir_all(&dir).unwrap();
372 }
373
374 #[test]
375 fn test_empty_patterns() {
376 let dir = std::env::temp_dir().join("reformat_replace_empty");
377 fs::create_dir_all(&dir).unwrap();
378
379 let file = dir.join("test.txt");
380 fs::write(&file, "content\n").unwrap();
381
382 let options = ReplaceOptions {
383 patterns: vec![],
384 ..Default::default()
385 };
386 let replacer = ContentReplacer::new(options).unwrap();
387 let (files, _) = replacer.process(&file).unwrap();
388
389 assert_eq!(files, 0);
390
391 fs::remove_dir_all(&dir).unwrap();
392 }
393
394 #[test]
395 fn test_recursive_replacement() {
396 let dir = std::env::temp_dir().join("reformat_replace_recursive");
397 fs::create_dir_all(&dir).unwrap();
398
399 let sub = dir.join("sub");
400 fs::create_dir_all(&sub).unwrap();
401
402 let f1 = dir.join("a.txt");
403 let f2 = sub.join("b.txt");
404 fs::write(&f1, "old\n").unwrap();
405 fs::write(&f2, "old\n").unwrap();
406
407 let options = ReplaceOptions {
408 patterns: vec![ReplacePattern {
409 find: "old".to_string(),
410 replace: "new".to_string(),
411 }],
412 ..Default::default()
413 };
414 let replacer = ContentReplacer::new(options).unwrap();
415 let (files, _) = replacer.process(&dir).unwrap();
416
417 assert_eq!(files, 2);
418
419 fs::remove_dir_all(&dir).unwrap();
420 }
421
422 #[test]
423 fn test_capture_group_replacement() {
424 let dir = std::env::temp_dir().join("reformat_replace_capture");
425 fs::create_dir_all(&dir).unwrap();
426
427 let file = dir.join("test.txt");
428 fs::write(&file, "func(a, b)\nfunc(x, y)\n").unwrap();
429
430 let options = ReplaceOptions {
431 patterns: vec![ReplacePattern {
432 find: r"func\((\w+), (\w+)\)".to_string(),
433 replace: "call($2, $1)".to_string(),
434 }],
435 ..Default::default()
436 };
437 let replacer = ContentReplacer::new(options).unwrap();
438 replacer.process(&file).unwrap();
439
440 let content = fs::read_to_string(&file).unwrap();
441 assert_eq!(content, "call(b, a)\ncall(y, x)\n");
442
443 fs::remove_dir_all(&dir).unwrap();
444 }
445}