1use regex::Regex;
4use std::fs;
5use std::path::Path;
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone)]
10pub struct HeaderOptions {
11 pub text: String,
13 pub update_year: bool,
15 pub file_extensions: Vec<String>,
17 pub recursive: bool,
19 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
41pub struct HeaderManager {
43 options: HeaderOptions,
44 resolved_header: String,
46 header_detector: Option<Regex>,
48}
49
50impl HeaderManager {
51 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 let header_detector =
63 if !resolved_header.is_empty() {
64 let escaped = regex::escape(&resolved_header);
65 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 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 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 if let Some(ref detector) = self.header_detector {
139 if let Some(m) = detector.find(&content) {
140 let existing = &content[m.start()..m.end()];
142 if existing == self.resolved_header {
143 return Ok(false);
145 }
146
147 let new_content = format!("{}{}", self.resolved_header, &content[m.end()..]);
149 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 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 pub fn process(&self, path: &Path) -> crate::Result<(usize, usize)> {
193 let mut total_files = 0;
194 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}