1use std::io::Write;
9use std::path::PathBuf;
10
11use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
12
13use crate::case::CaseConvention;
14
15const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
18
19#[derive(Debug)]
23pub struct FileCreateFixer {
24 path: PathBuf,
25 content: String,
26 create_parents: bool,
27}
28
29impl FileCreateFixer {
30 pub fn new(path: PathBuf, content: String, create_parents: bool) -> Self {
31 Self {
32 path,
33 content,
34 create_parents,
35 }
36 }
37}
38
39impl Fixer for FileCreateFixer {
40 fn describe(&self) -> String {
41 format!(
42 "create {} ({} byte{})",
43 self.path.display(),
44 self.content.len(),
45 if self.content.len() == 1 { "" } else { "s" }
46 )
47 }
48
49 fn apply(&self, _violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
50 let abs = ctx.root.join(&self.path);
51 if abs.exists() {
52 return Ok(FixOutcome::Skipped(format!(
53 "{} already exists",
54 self.path.display()
55 )));
56 }
57 if ctx.dry_run {
58 return Ok(FixOutcome::Applied(format!(
59 "would create {}",
60 self.path.display()
61 )));
62 }
63 if self.create_parents {
64 if let Some(parent) = abs.parent() {
65 std::fs::create_dir_all(parent).map_err(|source| Error::Io {
66 path: parent.to_path_buf(),
67 source,
68 })?;
69 }
70 }
71 std::fs::write(&abs, &self.content).map_err(|source| Error::Io {
72 path: abs.clone(),
73 source,
74 })?;
75 Ok(FixOutcome::Applied(format!(
76 "created {}",
77 self.path.display()
78 )))
79 }
80}
81
82#[derive(Debug)]
85pub struct FileRemoveFixer;
86
87impl Fixer for FileRemoveFixer {
88 fn describe(&self) -> String {
89 "remove the violating file".to_string()
90 }
91
92 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
93 let Some(path) = &violation.path else {
94 return Ok(FixOutcome::Skipped(
95 "violation did not carry a path".to_string(),
96 ));
97 };
98 let abs = ctx.root.join(path);
99 if !abs.exists() {
100 return Ok(FixOutcome::Skipped(format!(
101 "{} does not exist",
102 path.display()
103 )));
104 }
105 if ctx.dry_run {
106 return Ok(FixOutcome::Applied(format!(
107 "would remove {}",
108 path.display()
109 )));
110 }
111 std::fs::remove_file(&abs).map_err(|source| Error::Io {
112 path: abs.clone(),
113 source,
114 })?;
115 Ok(FixOutcome::Applied(format!("removed {}", path.display())))
116 }
117}
118
119#[derive(Debug)]
125pub struct FilePrependFixer {
126 content: String,
127}
128
129impl FilePrependFixer {
130 pub fn new(content: String) -> Self {
131 Self { content }
132 }
133}
134
135impl Fixer for FilePrependFixer {
136 fn describe(&self) -> String {
137 format!(
138 "prepend {} byte{} to each violating file",
139 self.content.len(),
140 if self.content.len() == 1 { "" } else { "s" }
141 )
142 }
143
144 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
145 let Some(path) = &violation.path else {
146 return Ok(FixOutcome::Skipped(
147 "violation did not carry a path".to_string(),
148 ));
149 };
150 let abs = ctx.root.join(path);
151 if ctx.dry_run {
152 return Ok(FixOutcome::Applied(format!(
153 "would prepend {} byte(s) to {}",
154 self.content.len(),
155 path.display()
156 )));
157 }
158 let existing = std::fs::read(&abs).map_err(|source| Error::Io {
159 path: abs.clone(),
160 source,
161 })?;
162 let mut out = Vec::with_capacity(existing.len() + self.content.len());
163 if existing.starts_with(UTF8_BOM) {
164 out.extend_from_slice(UTF8_BOM);
165 out.extend_from_slice(self.content.as_bytes());
166 out.extend_from_slice(&existing[UTF8_BOM.len()..]);
167 } else {
168 out.extend_from_slice(self.content.as_bytes());
169 out.extend_from_slice(&existing);
170 }
171 std::fs::write(&abs, &out).map_err(|source| Error::Io {
172 path: abs.clone(),
173 source,
174 })?;
175 Ok(FixOutcome::Applied(format!("prepended {}", path.display())))
176 }
177}
178
179#[derive(Debug)]
183pub struct FileAppendFixer {
184 content: String,
185}
186
187impl FileAppendFixer {
188 pub fn new(content: String) -> Self {
189 Self { content }
190 }
191}
192
193impl Fixer for FileAppendFixer {
194 fn describe(&self) -> String {
195 format!(
196 "append {} byte{} to each violating file",
197 self.content.len(),
198 if self.content.len() == 1 { "" } else { "s" }
199 )
200 }
201
202 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
203 let Some(path) = &violation.path else {
204 return Ok(FixOutcome::Skipped(
205 "violation did not carry a path".to_string(),
206 ));
207 };
208 let abs = ctx.root.join(path);
209 if ctx.dry_run {
210 return Ok(FixOutcome::Applied(format!(
211 "would append {} byte(s) to {}",
212 self.content.len(),
213 path.display()
214 )));
215 }
216 let mut f = std::fs::OpenOptions::new()
217 .append(true)
218 .open(&abs)
219 .map_err(|source| Error::Io {
220 path: abs.clone(),
221 source,
222 })?;
223 f.write_all(self.content.as_bytes())
224 .map_err(|source| Error::Io {
225 path: abs.clone(),
226 source,
227 })?;
228 Ok(FixOutcome::Applied(format!(
229 "appended to {}",
230 path.display()
231 )))
232 }
233}
234
235#[derive(Debug)]
243pub struct FileRenameFixer {
244 case: CaseConvention,
245}
246
247impl FileRenameFixer {
248 pub fn new(case: CaseConvention) -> Self {
249 Self { case }
250 }
251}
252
253impl Fixer for FileRenameFixer {
254 fn describe(&self) -> String {
255 format!("rename stems to {}", self.case.display_name())
256 }
257
258 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
259 let Some(path) = &violation.path else {
260 return Ok(FixOutcome::Skipped(
261 "violation did not carry a path".to_string(),
262 ));
263 };
264 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
265 return Ok(FixOutcome::Skipped(format!(
266 "cannot decode filename stem for {}",
267 path.display()
268 )));
269 };
270 let new_stem = self.case.convert(stem);
271 if new_stem == stem {
272 return Ok(FixOutcome::Skipped(format!(
273 "{} already matches target case",
274 path.display()
275 )));
276 }
277 if new_stem.is_empty() {
278 return Ok(FixOutcome::Skipped(format!(
279 "case conversion produced an empty stem for {}",
280 path.display()
281 )));
282 }
283
284 let mut new_basename = new_stem;
285 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
286 new_basename.push('.');
287 new_basename.push_str(ext);
288 }
289 let new_path: PathBuf = match path.parent() {
290 Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
291 _ => PathBuf::from(&new_basename),
292 };
293
294 let abs_from = ctx.root.join(path);
295 let abs_to = ctx.root.join(&new_path);
296 if abs_to.exists() {
297 return Ok(FixOutcome::Skipped(format!(
298 "target {} already exists",
299 new_path.display()
300 )));
301 }
302 if ctx.dry_run {
303 return Ok(FixOutcome::Applied(format!(
304 "would rename {} → {}",
305 path.display(),
306 new_path.display()
307 )));
308 }
309 std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
310 path: abs_from,
311 source,
312 })?;
313 Ok(FixOutcome::Applied(format!(
314 "renamed {} → {}",
315 path.display(),
316 new_path.display()
317 )))
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use tempfile::TempDir;
325
326 fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
327 FixContext {
328 root: tmp.path(),
329 dry_run,
330 }
331 }
332
333 #[test]
334 fn file_create_writes_content_when_missing() {
335 let tmp = TempDir::new().unwrap();
336 let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
337 let outcome = fixer
338 .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
339 .unwrap();
340 assert!(matches!(outcome, FixOutcome::Applied(_)));
341 let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
342 assert_eq!(written, "Apache-2.0\n");
343 }
344
345 #[test]
346 fn file_create_creates_intermediate_directories() {
347 let tmp = TempDir::new().unwrap();
348 let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
349 fixer
350 .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
351 .unwrap();
352 assert!(tmp.path().join("a/b/c/config.yaml").exists());
353 }
354
355 #[test]
356 fn file_create_skips_when_target_exists() {
357 let tmp = TempDir::new().unwrap();
358 std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
359 let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
360 let outcome = fixer
361 .apply(&Violation::new("x"), &make_ctx(&tmp, false))
362 .unwrap();
363 match outcome {
364 FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
365 FixOutcome::Applied(_) => panic!("expected Skipped"),
366 }
367 assert_eq!(
368 std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
369 "existing\n",
370 "pre-existing content must not be overwritten"
371 );
372 }
373
374 #[test]
375 fn file_create_dry_run_does_not_touch_disk() {
376 let tmp = TempDir::new().unwrap();
377 let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
378 let outcome = fixer
379 .apply(&Violation::new("x"), &make_ctx(&tmp, true))
380 .unwrap();
381 match outcome {
382 FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
383 FixOutcome::Skipped(_) => panic!("expected Applied"),
384 }
385 assert!(!tmp.path().join("x.txt").exists());
386 }
387
388 #[test]
389 fn file_remove_deletes_violating_path() {
390 let tmp = TempDir::new().unwrap();
391 let target = tmp.path().join("debug.log");
392 std::fs::write(&target, "noise").unwrap();
393 let outcome = FileRemoveFixer
394 .apply(
395 &Violation::new("forbidden").with_path("debug.log"),
396 &make_ctx(&tmp, false),
397 )
398 .unwrap();
399 assert!(matches!(outcome, FixOutcome::Applied(_)));
400 assert!(!target.exists());
401 }
402
403 #[test]
404 fn file_remove_skips_when_violation_has_no_path() {
405 let tmp = TempDir::new().unwrap();
406 let outcome = FileRemoveFixer
407 .apply(&Violation::new("no path"), &make_ctx(&tmp, false))
408 .unwrap();
409 match outcome {
410 FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
411 FixOutcome::Applied(_) => panic!("expected Skipped"),
412 }
413 }
414
415 #[test]
416 fn file_remove_dry_run_keeps_the_file() {
417 let tmp = TempDir::new().unwrap();
418 let target = tmp.path().join("victim.bak");
419 std::fs::write(&target, "bytes").unwrap();
420 let outcome = FileRemoveFixer
421 .apply(
422 &Violation::new("forbidden").with_path("victim.bak"),
423 &make_ctx(&tmp, true),
424 )
425 .unwrap();
426 match outcome {
427 FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
428 FixOutcome::Skipped(_) => panic!("expected Applied"),
429 }
430 assert!(target.exists());
431 }
432
433 #[test]
434 fn file_prepend_inserts_at_start() {
435 let tmp = TempDir::new().unwrap();
436 std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
437 let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
438 fixer
439 .apply(
440 &Violation::new("missing header").with_path("a.rs"),
441 &make_ctx(&tmp, false),
442 )
443 .unwrap();
444 assert_eq!(
445 std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
446 "// Copyright 2026\nfn main() {}\n"
447 );
448 }
449
450 #[test]
451 fn file_prepend_preserves_utf8_bom() {
452 let tmp = TempDir::new().unwrap();
453 let mut bytes = b"\xEF\xBB\xBF".to_vec();
455 bytes.extend_from_slice(b"hello\n");
456 std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
457 let fixer = FilePrependFixer::new("HEAD\n".into());
458 fixer
459 .apply(
460 &Violation::new("m").with_path("x.txt"),
461 &make_ctx(&tmp, false),
462 )
463 .unwrap();
464 let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
465 assert_eq!(&got[..3], b"\xEF\xBB\xBF");
466 assert_eq!(&got[3..], b"HEAD\nhello\n");
467 }
468
469 #[test]
470 fn file_prepend_dry_run_does_not_touch_disk() {
471 let tmp = TempDir::new().unwrap();
472 std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
473 FilePrependFixer::new("HEAD\n".into())
474 .apply(
475 &Violation::new("m").with_path("a.rs"),
476 &make_ctx(&tmp, true),
477 )
478 .unwrap();
479 assert_eq!(
480 std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
481 "original\n"
482 );
483 }
484
485 #[test]
486 fn file_prepend_skips_when_violation_has_no_path() {
487 let tmp = TempDir::new().unwrap();
488 let outcome = FilePrependFixer::new("h".into())
489 .apply(&Violation::new("m"), &make_ctx(&tmp, false))
490 .unwrap();
491 assert!(matches!(outcome, FixOutcome::Skipped(_)));
492 }
493
494 #[test]
495 fn file_append_writes_at_end() {
496 let tmp = TempDir::new().unwrap();
497 std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
498 let fixer = FileAppendFixer::new("\n## Section\n".into());
499 fixer
500 .apply(
501 &Violation::new("missing section").with_path("notes.md"),
502 &make_ctx(&tmp, false),
503 )
504 .unwrap();
505 assert_eq!(
506 std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
507 "# Notes\n\n## Section\n"
508 );
509 }
510
511 #[test]
512 fn file_append_dry_run_leaves_file_unchanged() {
513 let tmp = TempDir::new().unwrap();
514 std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
515 FileAppendFixer::new("extra\n".into())
516 .apply(
517 &Violation::new("m").with_path("x.txt"),
518 &make_ctx(&tmp, true),
519 )
520 .unwrap();
521 assert_eq!(
522 std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
523 "orig\n"
524 );
525 }
526
527 #[test]
528 fn file_append_skips_when_violation_has_no_path() {
529 let tmp = TempDir::new().unwrap();
530 let outcome = FileAppendFixer::new("x".into())
531 .apply(&Violation::new("m"), &make_ctx(&tmp, false))
532 .unwrap();
533 assert!(matches!(outcome, FixOutcome::Skipped(_)));
534 }
535
536 #[test]
537 fn file_rename_converts_stem_preserving_extension() {
538 let tmp = TempDir::new().unwrap();
539 std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
540 FileRenameFixer::new(CaseConvention::Snake)
541 .apply(
542 &Violation::new("case").with_path("FooBar.rs"),
543 &make_ctx(&tmp, false),
544 )
545 .unwrap();
546 assert!(tmp.path().join("foo_bar.rs").exists());
547 assert!(!tmp.path().join("FooBar.rs").exists());
548 }
549
550 #[test]
551 fn file_rename_keeps_file_in_same_directory() {
552 let tmp = TempDir::new().unwrap();
553 std::fs::create_dir(tmp.path().join("src")).unwrap();
554 std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
555 FileRenameFixer::new(CaseConvention::Snake)
556 .apply(
557 &Violation::new("case").with_path("src/MyModule.rs"),
558 &make_ctx(&tmp, false),
559 )
560 .unwrap();
561 assert!(tmp.path().join("src/my_module.rs").exists());
562 }
563
564 #[test]
565 fn file_rename_skips_when_already_in_target_case() {
566 let tmp = TempDir::new().unwrap();
567 std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
568 let outcome = FileRenameFixer::new(CaseConvention::Snake)
569 .apply(
570 &Violation::new("case").with_path("foo_bar.rs"),
571 &make_ctx(&tmp, false),
572 )
573 .unwrap();
574 match outcome {
575 FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
576 FixOutcome::Applied(_) => panic!("expected Skipped"),
577 }
578 }
579
580 #[test]
581 fn file_rename_skips_on_target_collision() {
582 let tmp = TempDir::new().unwrap();
583 std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
584 std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
585 let outcome = FileRenameFixer::new(CaseConvention::Snake)
586 .apply(
587 &Violation::new("case").with_path("FooBar.rs"),
588 &make_ctx(&tmp, false),
589 )
590 .unwrap();
591 match outcome {
592 FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
593 FixOutcome::Applied(_) => panic!("expected Skipped"),
594 }
595 assert_eq!(
597 std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
598 "A"
599 );
600 assert_eq!(
601 std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
602 "B"
603 );
604 }
605
606 #[test]
607 fn file_rename_dry_run_does_not_touch_disk() {
608 let tmp = TempDir::new().unwrap();
609 std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
610 FileRenameFixer::new(CaseConvention::Snake)
611 .apply(
612 &Violation::new("case").with_path("FooBar.rs"),
613 &make_ctx(&tmp, true),
614 )
615 .unwrap();
616 assert!(tmp.path().join("FooBar.rs").exists());
617 assert!(!tmp.path().join("foo_bar.rs").exists());
618 }
619}