1use std::io::Write;
2use std::path::PathBuf;
3
4use alint_core::{ContentSourceSpec, Error, FixContext, FixOutcome, Fixer, Result, Violation};
5
6const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
9
10#[derive(Debug)]
15pub struct FileCreateFixer {
16 path: PathBuf,
17 source: ContentSourceSpec,
18 create_parents: bool,
19}
20
21impl FileCreateFixer {
22 pub fn new(path: PathBuf, source: ContentSourceSpec, create_parents: bool) -> Self {
23 Self {
24 path,
25 source,
26 create_parents,
27 }
28 }
29}
30
31impl Fixer for FileCreateFixer {
32 fn describe(&self) -> String {
33 match &self.source {
34 ContentSourceSpec::Inline(s) => format!(
35 "create {} ({} byte{})",
36 self.path.display(),
37 s.len(),
38 if s.len() == 1 { "" } else { "s" }
39 ),
40 ContentSourceSpec::File(rel) => format!(
41 "create {} (content from {})",
42 self.path.display(),
43 rel.display()
44 ),
45 }
46 }
47
48 fn apply(&self, _violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
49 let abs = ctx.root.join(&self.path);
50 if abs.exists() {
51 return Ok(FixOutcome::Skipped(format!(
52 "{} already exists",
53 self.path.display()
54 )));
55 }
56 let content = match resolve_source_bytes(&self.source, ctx.root) {
57 Ok(bytes) => bytes,
58 Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
59 };
60 if ctx.dry_run {
61 return Ok(FixOutcome::Applied(format!(
62 "would create {}",
63 self.path.display()
64 )));
65 }
66 if self.create_parents
67 && let Some(parent) = abs.parent()
68 {
69 std::fs::create_dir_all(parent).map_err(|source| Error::Io {
70 path: parent.to_path_buf(),
71 source,
72 })?;
73 }
74 std::fs::write(&abs, &content).map_err(|source| Error::Io {
75 path: abs.clone(),
76 source,
77 })?;
78 Ok(FixOutcome::Applied(format!(
79 "created {}",
80 self.path.display()
81 )))
82 }
83}
84
85fn resolve_source_bytes(
92 source: &ContentSourceSpec,
93 ctx_root: &std::path::Path,
94) -> std::result::Result<Vec<u8>, String> {
95 match source {
96 ContentSourceSpec::Inline(s) => Ok(s.as_bytes().to_vec()),
97 ContentSourceSpec::File(rel) => {
98 let abs = ctx_root.join(rel);
99 std::fs::read(&abs)
100 .map_err(|e| format!("content_from `{}` could not be read: {e}", rel.display()))
101 }
102 }
103}
104
105#[derive(Debug)]
112pub struct FilePrependFixer {
113 source: ContentSourceSpec,
114}
115
116impl FilePrependFixer {
117 pub fn new(source: ContentSourceSpec) -> Self {
118 Self { source }
119 }
120}
121
122impl Fixer for FilePrependFixer {
123 fn describe(&self) -> String {
124 match &self.source {
125 ContentSourceSpec::Inline(s) => format!(
126 "prepend {} byte{} to each violating file",
127 s.len(),
128 if s.len() == 1 { "" } else { "s" }
129 ),
130 ContentSourceSpec::File(rel) => {
131 format!(
132 "prepend content from {} to each violating file",
133 rel.display()
134 )
135 }
136 }
137 }
138
139 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
140 let Some(path) = &violation.path else {
141 return Ok(FixOutcome::Skipped(
142 "violation did not carry a path".to_string(),
143 ));
144 };
145 let abs = ctx.root.join(path);
146 let prepend = match resolve_source_bytes(&self.source, ctx.root) {
147 Ok(b) => b,
148 Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
149 };
150 if ctx.dry_run {
151 return Ok(FixOutcome::Applied(format!(
152 "would prepend {} byte(s) to {}",
153 prepend.len(),
154 path.display()
155 )));
156 }
157 let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
158 alint_core::ReadForFix::Bytes(b) => b,
159 alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
160 };
161 let mut out = Vec::with_capacity(existing.len() + prepend.len());
162 if existing.starts_with(UTF8_BOM) {
163 out.extend_from_slice(UTF8_BOM);
164 out.extend_from_slice(&prepend);
165 out.extend_from_slice(&existing[UTF8_BOM.len()..]);
166 } else {
167 out.extend_from_slice(&prepend);
168 out.extend_from_slice(&existing);
169 }
170 std::fs::write(&abs, &out).map_err(|source| Error::Io {
171 path: abs.clone(),
172 source,
173 })?;
174 Ok(FixOutcome::Applied(format!("prepended {}", path.display())))
175 }
176}
177
178#[derive(Debug)]
182pub struct FileAppendFixer {
183 source: ContentSourceSpec,
184}
185
186impl FileAppendFixer {
187 pub fn new(source: ContentSourceSpec) -> Self {
188 Self { source }
189 }
190}
191
192impl Fixer for FileAppendFixer {
193 fn describe(&self) -> String {
194 match &self.source {
195 ContentSourceSpec::Inline(s) => format!(
196 "append {} byte{} to each violating file",
197 s.len(),
198 if s.len() == 1 { "" } else { "s" }
199 ),
200 ContentSourceSpec::File(rel) => {
201 format!(
202 "append content from {} to each violating file",
203 rel.display()
204 )
205 }
206 }
207 }
208
209 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
210 let Some(path) = &violation.path else {
211 return Ok(FixOutcome::Skipped(
212 "violation did not carry a path".to_string(),
213 ));
214 };
215 let abs = ctx.root.join(path);
216 let payload = match resolve_source_bytes(&self.source, ctx.root) {
217 Ok(b) => b,
218 Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
219 };
220 if ctx.dry_run {
221 return Ok(FixOutcome::Applied(format!(
222 "would append {} byte(s) to {}",
223 payload.len(),
224 path.display()
225 )));
226 }
227 if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
228 return Ok(skip);
229 }
230 let mut f = std::fs::OpenOptions::new()
231 .append(true)
232 .open(&abs)
233 .map_err(|source| Error::Io {
234 path: abs.clone(),
235 source,
236 })?;
237 f.write_all(&payload).map_err(|source| Error::Io {
238 path: abs.clone(),
239 source,
240 })?;
241 Ok(FixOutcome::Applied(format!(
242 "appended to {}",
243 path.display()
244 )))
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use tempfile::TempDir;
252
253 fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
254 FixContext {
255 root: tmp.path(),
256 dry_run,
257 fix_size_limit: None,
258 }
259 }
260
261 #[test]
262 fn file_create_writes_content_when_missing() {
263 let tmp = TempDir::new().unwrap();
264 let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
265 let outcome = fixer
266 .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
267 .unwrap();
268 assert!(matches!(outcome, FixOutcome::Applied(_)));
269 let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
270 assert_eq!(written, "Apache-2.0\n");
271 }
272
273 #[test]
274 fn file_create_reads_content_from_relative_path() {
275 let tmp = TempDir::new().unwrap();
280 let template_dir = tmp.path().join(".alint/templates");
281 std::fs::create_dir_all(&template_dir).unwrap();
282 std::fs::write(
283 template_dir.join("LICENSE-MIT.txt"),
284 "MIT License\n\nCopyright (c) 2026 demo\n",
285 )
286 .unwrap();
287 let fixer = FileCreateFixer::new(
288 PathBuf::from("LICENSE"),
289 ContentSourceSpec::File(PathBuf::from(".alint/templates/LICENSE-MIT.txt")),
290 true,
291 );
292 let outcome = fixer
293 .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
294 .unwrap();
295 assert!(matches!(outcome, FixOutcome::Applied(_)));
296 let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
297 assert!(written.starts_with("MIT License"));
298 assert!(written.contains("Copyright (c) 2026"));
299 }
300
301 #[test]
302 fn file_create_skips_when_content_from_missing() {
303 let tmp = TempDir::new().unwrap();
307 let fixer = FileCreateFixer::new(
308 PathBuf::from("LICENSE"),
309 ContentSourceSpec::File(PathBuf::from("does/not/exist.txt")),
310 true,
311 );
312 let outcome = fixer
313 .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
314 .unwrap();
315 let FixOutcome::Skipped(msg) = &outcome else {
316 panic!("expected Skipped, got {outcome:?}")
317 };
318 assert!(msg.contains("could not be read"));
319 assert!(!tmp.path().join("LICENSE").exists());
322 }
323
324 #[test]
325 fn file_prepend_with_content_from_reads_at_apply() {
326 let tmp = TempDir::new().unwrap();
327 std::fs::write(
328 tmp.path().join("hdr.txt"),
329 "// SPDX-License-Identifier: MIT\n",
330 )
331 .unwrap();
332 std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
333 let fixer = FilePrependFixer::new(ContentSourceSpec::File(PathBuf::from("hdr.txt")));
334 let outcome = fixer
335 .apply(
336 &Violation::new("missing header").with_path(PathBuf::from("a.rs")),
337 &make_ctx(&tmp, false),
338 )
339 .unwrap();
340 assert!(matches!(outcome, FixOutcome::Applied(_)));
341 let updated = std::fs::read_to_string(tmp.path().join("a.rs")).unwrap();
342 assert!(updated.starts_with("// SPDX-License-Identifier: MIT\n"));
343 assert!(updated.contains("fn main() {}"));
344 }
345
346 #[test]
347 fn file_create_creates_intermediate_directories() {
348 let tmp = TempDir::new().unwrap();
349 let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
350 fixer
351 .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
352 .unwrap();
353 assert!(tmp.path().join("a/b/c/config.yaml").exists());
354 }
355
356 #[test]
357 fn file_create_skips_when_target_exists() {
358 let tmp = TempDir::new().unwrap();
359 std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
360 let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
361 let outcome = fixer
362 .apply(&Violation::new("x"), &make_ctx(&tmp, false))
363 .unwrap();
364 match outcome {
365 FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
366 FixOutcome::Applied(_) => panic!("expected Skipped"),
367 }
368 assert_eq!(
369 std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
370 "existing\n",
371 "pre-existing content must not be overwritten"
372 );
373 }
374
375 #[test]
376 fn file_create_dry_run_does_not_touch_disk() {
377 let tmp = TempDir::new().unwrap();
378 let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
379 let outcome = fixer
380 .apply(&Violation::new("x"), &make_ctx(&tmp, true))
381 .unwrap();
382 match outcome {
383 FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
384 FixOutcome::Skipped(_) => panic!("expected Applied"),
385 }
386 assert!(!tmp.path().join("x.txt").exists());
387 }
388
389 #[test]
390 fn file_prepend_inserts_at_start() {
391 let tmp = TempDir::new().unwrap();
392 std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
393 let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
394 fixer
395 .apply(
396 &Violation::new("missing header").with_path(std::path::Path::new("a.rs")),
397 &make_ctx(&tmp, false),
398 )
399 .unwrap();
400 assert_eq!(
401 std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
402 "// Copyright 2026\nfn main() {}\n"
403 );
404 }
405
406 #[test]
407 fn file_prepend_preserves_utf8_bom() {
408 let tmp = TempDir::new().unwrap();
409 let mut bytes = b"\xEF\xBB\xBF".to_vec();
411 bytes.extend_from_slice(b"hello\n");
412 std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
413 let fixer = FilePrependFixer::new("HEAD\n".into());
414 fixer
415 .apply(
416 &Violation::new("m").with_path(std::path::Path::new("x.txt")),
417 &make_ctx(&tmp, false),
418 )
419 .unwrap();
420 let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
421 assert_eq!(&got[..3], b"\xEF\xBB\xBF");
422 assert_eq!(&got[3..], b"HEAD\nhello\n");
423 }
424
425 #[test]
426 fn file_prepend_dry_run_does_not_touch_disk() {
427 let tmp = TempDir::new().unwrap();
428 std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
429 FilePrependFixer::new("HEAD\n".into())
430 .apply(
431 &Violation::new("m").with_path(std::path::Path::new("a.rs")),
432 &make_ctx(&tmp, true),
433 )
434 .unwrap();
435 assert_eq!(
436 std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
437 "original\n"
438 );
439 }
440
441 #[test]
442 fn file_prepend_skips_when_violation_has_no_path() {
443 let tmp = TempDir::new().unwrap();
444 let outcome = FilePrependFixer::new("h".into())
445 .apply(&Violation::new("m"), &make_ctx(&tmp, false))
446 .unwrap();
447 assert!(matches!(outcome, FixOutcome::Skipped(_)));
448 }
449
450 #[test]
451 fn file_append_writes_at_end() {
452 let tmp = TempDir::new().unwrap();
453 std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
454 let fixer = FileAppendFixer::new("\n## Section\n".into());
455 fixer
456 .apply(
457 &Violation::new("missing section").with_path(std::path::Path::new("notes.md")),
458 &make_ctx(&tmp, false),
459 )
460 .unwrap();
461 assert_eq!(
462 std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
463 "# Notes\n\n## Section\n"
464 );
465 }
466
467 #[test]
468 fn file_append_dry_run_leaves_file_unchanged() {
469 let tmp = TempDir::new().unwrap();
470 std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
471 FileAppendFixer::new("extra\n".into())
472 .apply(
473 &Violation::new("m").with_path(std::path::Path::new("x.txt")),
474 &make_ctx(&tmp, true),
475 )
476 .unwrap();
477 assert_eq!(
478 std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
479 "orig\n"
480 );
481 }
482
483 #[test]
484 fn file_append_skips_when_violation_has_no_path() {
485 let tmp = TempDir::new().unwrap();
486 let outcome = FileAppendFixer::new("x".into())
487 .apply(&Violation::new("m"), &make_ctx(&tmp, false))
488 .unwrap();
489 assert!(matches!(outcome, FixOutcome::Skipped(_)));
490 }
491}