Skip to main content

alint_rules/fixers/
creators.rs

1use std::io::Write;
2use std::path::PathBuf;
3
4use alint_core::{ContentSourceSpec, Error, FixContext, FixOutcome, Fixer, Result, Violation};
5
6/// UTF-8 byte-order mark. Preserved across prepend operations so
7/// editors that rely on it don't break.
8const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
9
10/// Creates a file with pre-declared content. Target path is set at
11/// rule-build time (either explicit `fix.file_create.path` or the
12/// rule's first literal `paths:` entry). Content is either inline
13/// or read at apply time from a path-relative-to-root.
14#[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
85/// Read a `ContentSourceSpec` to bytes. Returns the raw payload
86/// for inline content; for file-sourced content, reads the file
87/// at apply time, resolving its path relative to `ctx_root`. A
88/// missing or unreadable source produces a `Skipped`-friendly
89/// `Err(String)` so the caller can degrade gracefully rather
90/// than abort the whole fix run.
91fn 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/// Prepends `source` content to the start of each violating
106/// file. Paired with `file_header` to inject a required header
107/// comment / boilerplate.
108///
109/// If the file starts with a UTF-8 BOM, the prepended bytes go
110/// *after* the BOM so editors that rely on it don't break.
111#[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/// Appends `source` content to the end of each violating file.
179/// Paired with `file_content_matches` / `file_footer` when the
180/// required content is satisfied by the appended bytes.
181#[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        // `content_from` relative to ctx.root: stage a template
276        // file in the tempdir, point the fixer at it via a
277        // relative path, and verify the apply step reads from
278        // disk at apply time.
279        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        // Missing source file produces a `Skipped` outcome
304        // rather than aborting the whole fix run — same posture
305        // as the rest of the fixer module.
306        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        // The target file should NOT have been created since
320        // we skipped before the write.
321        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        // BOM + "hello\n"
410        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}