Skip to main content

reformat_core/
header.rs

1//! File header management transformer
2
3use regex::Regex;
4use std::fs;
5use std::path::Path;
6use walkdir::WalkDir;
7
8/// Options for header management
9#[derive(Debug, Clone)]
10pub struct HeaderOptions {
11    /// The header text to insert (without comment markers -- those are part of the text)
12    pub text: String,
13    /// If true, replace {year} in the header text with the current year
14    pub update_year: bool,
15    /// File extensions to process
16    pub file_extensions: Vec<String>,
17    /// Process directories recursively
18    pub recursive: bool,
19    /// Dry run mode (don't modify files)
20    pub dry_run: bool,
21}
22
23impl Default for HeaderOptions {
24    fn default() -> Self {
25        HeaderOptions {
26            text: String::new(),
27            update_year: false,
28            file_extensions: vec![
29                ".py", ".pyx", ".pxd", ".pxi", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".java",
30                ".js", ".ts", ".jsx", ".tsx",
31            ]
32            .iter()
33            .map(|s| s.to_string())
34            .collect(),
35            recursive: true,
36            dry_run: false,
37        }
38    }
39}
40
41/// File header manager: insert or update headers at the top of source files
42pub struct HeaderManager {
43    options: HeaderOptions,
44    /// The resolved header text (with year substitution applied)
45    resolved_header: String,
46    /// Regex to detect if the header (or a year-variant of it) already exists
47    header_detector: Option<Regex>,
48}
49
50impl HeaderManager {
51    /// Creates a new header manager with the given options
52    pub fn new(options: HeaderOptions) -> crate::Result<Self> {
53        let resolved_header = if options.update_year {
54            let year = chrono::Utc::now().format("%Y").to_string();
55            options.text.replace("{year}", &year)
56        } else {
57            options.text.clone()
58        };
59
60        // Build a detector regex: escape the header text but replace any 4-digit year
61        // with \d{4} so we can find year-variant headers
62        let header_detector =
63            if !resolved_header.is_empty() {
64                let escaped = regex::escape(&resolved_header);
65                // Replace any 4-digit year (19xx or 20xx) with a flexible year pattern
66                let flexible = Regex::new(r"(?:19|20)\d\{2\}")
67                    .unwrap()
68                    .replace_all(&escaped, r"\d{4}")
69                    .to_string();
70                Some(Regex::new(&flexible).map_err(|e| {
71                    anyhow::anyhow!("failed to compile header detection regex: {}", e)
72                })?)
73            } else {
74                None
75            };
76
77        Ok(HeaderManager {
78            options,
79            resolved_header,
80            header_detector,
81        })
82    }
83
84    /// Checks if a file should be processed
85    fn should_process(&self, path: &Path) -> bool {
86        if !path.is_file() {
87            return false;
88        }
89
90        if path.components().any(|c| {
91            c.as_os_str()
92                .to_str()
93                .map(|s| s.starts_with('.'))
94                .unwrap_or(false)
95        }) {
96            return false;
97        }
98
99        let skip_dirs = [
100            "build",
101            "__pycache__",
102            ".git",
103            "node_modules",
104            "venv",
105            ".venv",
106            "target",
107        ];
108        if path.components().any(|c| {
109            c.as_os_str()
110                .to_str()
111                .map(|s| skip_dirs.contains(&s))
112                .unwrap_or(false)
113        }) {
114            return false;
115        }
116
117        if let Some(ext) = path.extension() {
118            let ext_str = format!(".{}", ext.to_string_lossy());
119            self.options.file_extensions.contains(&ext_str)
120        } else {
121            false
122        }
123    }
124
125    /// Process a single file. Returns true if the file was modified (or would be in dry-run).
126    pub fn process_file(&self, path: &Path) -> crate::Result<bool> {
127        if !self.should_process(path) {
128            return Ok(false);
129        }
130
131        if self.resolved_header.is_empty() {
132            return Ok(false);
133        }
134
135        let content = fs::read_to_string(path)?;
136
137        // Check if header already exists (possibly with a different year)
138        if let Some(ref detector) = self.header_detector {
139            if let Some(m) = detector.find(&content) {
140                // Header exists -- check if it needs a year update
141                let existing = &content[m.start()..m.end()];
142                if existing == self.resolved_header {
143                    // Exact match, nothing to do
144                    return Ok(false);
145                }
146
147                // Replace old header with new one (year update)
148                let new_content = format!("{}{}", self.resolved_header, &content[m.end()..]);
149                // Preserve content before the header (e.g., shebang lines)
150                let prefix = &content[..m.start()];
151                let full = format!("{}{}", prefix, new_content);
152
153                if self.options.dry_run {
154                    println!("Would update header in '{}'", path.display());
155                } else {
156                    fs::write(path, &full)?;
157                    println!("Updated header in '{}'", path.display());
158                }
159                return Ok(true);
160            }
161        }
162
163        // Header doesn't exist -- insert it
164        // Preserve shebang lines (e.g., #!/usr/bin/env python)
165        let (prefix, rest) = if content.starts_with("#!") {
166            if let Some(pos) = content.find('\n') {
167                (&content[..=pos], &content[pos + 1..])
168            } else {
169                (content.as_str(), "")
170            }
171        } else {
172            ("", content.as_str())
173        };
174
175        let new_content = if prefix.is_empty() {
176            format!("{}\n\n{}", self.resolved_header, rest)
177        } else {
178            format!("{}{}\n\n{}", prefix, self.resolved_header, rest)
179        };
180
181        if self.options.dry_run {
182            println!("Would insert header in '{}'", path.display());
183        } else {
184            fs::write(path, &new_content)?;
185            println!("Inserted header in '{}'", path.display());
186        }
187
188        Ok(true)
189    }
190
191    /// Processes a directory or file. Returns (files_changed, operation_description).
192    pub fn process(&self, path: &Path) -> crate::Result<(usize, usize)> {
193        let mut total_files = 0;
194        // We use the second value as "operations" (1 per file touched)
195        let mut total_ops = 0;
196
197        if path.is_file() {
198            if self.process_file(path)? {
199                total_files = 1;
200                total_ops = 1;
201            }
202        } else if path.is_dir() {
203            if self.options.recursive {
204                for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
205                    if entry.file_type().is_file() && self.process_file(entry.path())? {
206                        total_files += 1;
207                        total_ops += 1;
208                    }
209                }
210            } else {
211                for entry in fs::read_dir(path)? {
212                    let entry = entry?;
213                    let entry_path = entry.path();
214                    if entry_path.is_file() && self.process_file(&entry_path)? {
215                        total_files += 1;
216                        total_ops += 1;
217                    }
218                }
219            }
220        }
221
222        Ok((total_files, total_ops))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::fs;
230
231    #[test]
232    fn test_insert_header() {
233        let dir = std::env::temp_dir().join("reformat_header_insert");
234        fs::create_dir_all(&dir).unwrap();
235
236        let file = dir.join("test.rs");
237        fs::write(&file, "fn main() {}\n").unwrap();
238
239        let options = HeaderOptions {
240            text: "// Copyright 2025 TestCorp".to_string(),
241            ..Default::default()
242        };
243        let manager = HeaderManager::new(options).unwrap();
244        let (files, _) = manager.process(&file).unwrap();
245
246        assert_eq!(files, 1);
247
248        let content = fs::read_to_string(&file).unwrap();
249        assert!(content.starts_with("// Copyright 2025 TestCorp\n\n"));
250        assert!(content.contains("fn main() {}"));
251
252        fs::remove_dir_all(&dir).unwrap();
253    }
254
255    #[test]
256    fn test_header_already_present() {
257        let dir = std::env::temp_dir().join("reformat_header_exists");
258        fs::create_dir_all(&dir).unwrap();
259
260        let file = dir.join("test.rs");
261        let original = "// Copyright 2025 TestCorp\n\nfn main() {}\n";
262        fs::write(&file, original).unwrap();
263
264        let options = HeaderOptions {
265            text: "// Copyright 2025 TestCorp".to_string(),
266            ..Default::default()
267        };
268        let manager = HeaderManager::new(options).unwrap();
269        let (files, _) = manager.process(&file).unwrap();
270
271        assert_eq!(files, 0);
272
273        let content = fs::read_to_string(&file).unwrap();
274        assert_eq!(content, original);
275
276        fs::remove_dir_all(&dir).unwrap();
277    }
278
279    #[test]
280    fn test_update_year_in_header() {
281        let dir = std::env::temp_dir().join("reformat_header_year");
282        fs::create_dir_all(&dir).unwrap();
283
284        let file = dir.join("test.rs");
285        fs::write(&file, "// Copyright 2020 TestCorp\n\nfn main() {}\n").unwrap();
286
287        let current_year = chrono::Utc::now().format("%Y").to_string();
288        let options = HeaderOptions {
289            text: format!("// Copyright {} TestCorp", current_year),
290            ..Default::default()
291        };
292        let manager = HeaderManager::new(options).unwrap();
293        let (files, _) = manager.process(&file).unwrap();
294
295        assert_eq!(files, 1);
296
297        let content = fs::read_to_string(&file).unwrap();
298        assert!(content.starts_with(&format!("// Copyright {} TestCorp", current_year)));
299
300        fs::remove_dir_all(&dir).unwrap();
301    }
302
303    #[test]
304    fn test_preserve_shebang() {
305        let dir = std::env::temp_dir().join("reformat_header_shebang");
306        fs::create_dir_all(&dir).unwrap();
307
308        let file = dir.join("test.py");
309        fs::write(&file, "#!/usr/bin/env python\nprint('hello')\n").unwrap();
310
311        let options = HeaderOptions {
312            text: "# Copyright 2025 TestCorp".to_string(),
313            file_extensions: vec![".py".to_string()],
314            ..Default::default()
315        };
316        let manager = HeaderManager::new(options).unwrap();
317        manager.process(&file).unwrap();
318
319        let content = fs::read_to_string(&file).unwrap();
320        assert!(content.starts_with("#!/usr/bin/env python\n"));
321        assert!(content.contains("# Copyright 2025 TestCorp"));
322        assert!(content.contains("print('hello')"));
323
324        fs::remove_dir_all(&dir).unwrap();
325    }
326
327    #[test]
328    fn test_dry_run() {
329        let dir = std::env::temp_dir().join("reformat_header_dry");
330        fs::create_dir_all(&dir).unwrap();
331
332        let file = dir.join("test.rs");
333        let original = "fn main() {}\n";
334        fs::write(&file, original).unwrap();
335
336        let options = HeaderOptions {
337            text: "// License Header".to_string(),
338            dry_run: true,
339            ..Default::default()
340        };
341        let manager = HeaderManager::new(options).unwrap();
342        let (files, _) = manager.process(&file).unwrap();
343
344        assert_eq!(files, 1);
345        let content = fs::read_to_string(&file).unwrap();
346        assert_eq!(content, original);
347
348        fs::remove_dir_all(&dir).unwrap();
349    }
350
351    #[test]
352    fn test_empty_header() {
353        let dir = std::env::temp_dir().join("reformat_header_empty");
354        fs::create_dir_all(&dir).unwrap();
355
356        let file = dir.join("test.rs");
357        fs::write(&file, "fn main() {}\n").unwrap();
358
359        let options = HeaderOptions {
360            text: String::new(),
361            ..Default::default()
362        };
363        let manager = HeaderManager::new(options).unwrap();
364        let (files, _) = manager.process(&file).unwrap();
365
366        assert_eq!(files, 0);
367
368        fs::remove_dir_all(&dir).unwrap();
369    }
370
371    #[test]
372    fn test_year_template_substitution() {
373        let dir = std::env::temp_dir().join("reformat_header_template");
374        fs::create_dir_all(&dir).unwrap();
375
376        let file = dir.join("test.rs");
377        fs::write(&file, "fn main() {}\n").unwrap();
378
379        let options = HeaderOptions {
380            text: "// Copyright {year} TestCorp".to_string(),
381            update_year: true,
382            ..Default::default()
383        };
384        let manager = HeaderManager::new(options).unwrap();
385        manager.process(&file).unwrap();
386
387        let current_year = chrono::Utc::now().format("%Y").to_string();
388        let content = fs::read_to_string(&file).unwrap();
389        assert!(content.contains(&format!("Copyright {} TestCorp", current_year)));
390
391        fs::remove_dir_all(&dir).unwrap();
392    }
393
394    #[test]
395    fn test_recursive_processing() {
396        let dir = std::env::temp_dir().join("reformat_header_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.rs");
403        let f2 = sub.join("b.rs");
404        fs::write(&f1, "fn a() {}\n").unwrap();
405        fs::write(&f2, "fn b() {}\n").unwrap();
406
407        let options = HeaderOptions {
408            text: "// Header".to_string(),
409            ..Default::default()
410        };
411        let manager = HeaderManager::new(options).unwrap();
412        let (files, _) = manager.process(&dir).unwrap();
413
414        assert_eq!(files, 2);
415
416        fs::remove_dir_all(&dir).unwrap();
417    }
418
419    #[test]
420    fn test_multiline_header() {
421        let dir = std::env::temp_dir().join("reformat_header_multiline");
422        fs::create_dir_all(&dir).unwrap();
423
424        let file = dir.join("test.rs");
425        fs::write(&file, "fn main() {}\n").unwrap();
426
427        let options = HeaderOptions {
428            text: "// Copyright 2025 TestCorp\n// Licensed under MIT\n// All rights reserved"
429                .to_string(),
430            ..Default::default()
431        };
432        let manager = HeaderManager::new(options).unwrap();
433        manager.process(&file).unwrap();
434
435        let content = fs::read_to_string(&file).unwrap();
436        assert!(content.starts_with(
437            "// Copyright 2025 TestCorp\n// Licensed under MIT\n// All rights reserved\n\n"
438        ));
439
440        fs::remove_dir_all(&dir).unwrap();
441    }
442}