Skip to main content

reformat_core/
replace.rs

1//! Regex find-and-replace transformer
2
3use regex::Regex;
4use std::fs;
5use std::path::Path;
6use walkdir::WalkDir;
7
8/// A single find-and-replace pattern
9#[derive(Debug, Clone)]
10pub struct ReplacePattern {
11    /// Regex pattern to search for
12    pub find: String,
13    /// Replacement string (supports capture groups: $1, $2, etc.)
14    pub replace: String,
15}
16
17/// Options for content replacement
18#[derive(Debug, Clone)]
19pub struct ReplaceOptions {
20    /// Ordered list of patterns to apply
21    pub patterns: Vec<ReplacePattern>,
22    /// File extensions to process
23    pub file_extensions: Vec<String>,
24    /// Process directories recursively
25    pub recursive: bool,
26    /// Dry run mode (don't modify files)
27    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/// Compiled replacement pattern
49#[derive(Debug)]
50struct CompiledPattern {
51    regex: Regex,
52    replace: String,
53}
54
55/// Content replacer that applies regex find-and-replace across files
56#[derive(Debug)]
57pub struct ContentReplacer {
58    options: ReplaceOptions,
59    compiled: Vec<CompiledPattern>,
60}
61
62impl ContentReplacer {
63    /// Creates a new replacer with the given options.
64    /// Returns an error if any regex pattern is invalid.
65    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    /// Checks if a file should be processed
79    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    /// Apply all patterns to a single file. Returns number of replacements made.
120    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(&current, cp.replace.as_str());
135            if result != current {
136                // Count individual matches for this pattern
137                let count = cp.regex.find_iter(&current).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, &current)?;
152                println!(
153                    "Made {} replacement(s) in '{}'",
154                    total_replacements,
155                    path.display()
156                );
157            }
158        }
159
160        Ok(total_replacements)
161    }
162
163    /// Processes a directory or file. Returns (files_changed, total_replacements).
164    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/// Serde-compatible pattern for config deserialization
205#[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}