Skip to main content

alint_rules/
file_ends_with.rs

1//! `file_ends_with` — every file in scope must end with the
2//! configured suffix (byte-level).
3//!
4//! Useful for required trailing banners ("<!-- end-of-file -->"),
5//! closing magic bytes, or enforcing a generated-file sentinel.
6//! For the narrower "file must end with a newline" check, prefer
7//! `final_newline` — it has a dedicated fixer and integrates with
8//! the text-hygiene family.
9//!
10//! Check-only: the correct fix is `file_append` with matching
11//! content. Auto-appending implicitly could silently duplicate a
12//! near-matching tail.
13
14use std::path::Path;
15
16use alint_core::{Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation};
17use serde::Deserialize;
18
19use crate::io::read_suffix_n;
20
21#[derive(Debug, Deserialize)]
22#[serde(deny_unknown_fields)]
23struct Options {
24    /// The required suffix. Matched byte-for-byte.
25    suffix: String,
26}
27
28#[derive(Debug)]
29pub struct FileEndsWithRule {
30    id: String,
31    level: Level,
32    policy_url: Option<String>,
33    message: Option<String>,
34    scope: Scope,
35    suffix: Vec<u8>,
36}
37
38impl Rule for FileEndsWithRule {
39    fn id(&self) -> &str {
40        &self.id
41    }
42    fn level(&self) -> Level {
43        self.level
44    }
45    fn policy_url(&self) -> Option<&str> {
46        self.policy_url.as_deref()
47    }
48
49    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
50        let mut violations = Vec::new();
51        for entry in ctx.index.files() {
52            if !self.scope.matches(&entry.path, ctx.index) {
53                continue;
54            }
55            // Bounded read: only the trailing `suffix.len()`
56            // bytes matter. Solo runs (`alint fix --only`,
57            // tests) read just those bytes from the end.
58            let full = ctx.root.join(&entry.path);
59            let Ok(tail) = read_suffix_n(&full, self.suffix.len()) else {
60                continue;
61            };
62            violations.extend(self.evaluate_file(ctx, &entry.path, &tail)?);
63        }
64        Ok(violations)
65    }
66
67    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
68        Some(self)
69    }
70}
71
72impl PerFileRule for FileEndsWithRule {
73    fn path_scope(&self) -> &Scope {
74        &self.scope
75    }
76
77    fn evaluate_file(
78        &self,
79        _ctx: &Context<'_>,
80        path: &Path,
81        bytes: &[u8],
82    ) -> Result<Vec<Violation>> {
83        if bytes.ends_with(&self.suffix) {
84            return Ok(Vec::new());
85        }
86        let msg = self
87            .message
88            .clone()
89            .unwrap_or_else(|| "file does not end with the required suffix".to_string());
90        Ok(vec![
91            Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
92        ])
93    }
94
95    fn max_bytes_needed(&self) -> Option<usize> {
96        Some(self.suffix.len())
97    }
98}
99
100pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
101    let _paths = spec
102        .paths
103        .as_ref()
104        .ok_or_else(|| Error::rule_config(&spec.id, "file_ends_with requires a `paths` field"))?;
105    let opts: Options = spec
106        .deserialize_options()
107        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
108    if opts.suffix.is_empty() {
109        return Err(Error::rule_config(
110            &spec.id,
111            "file_ends_with.suffix must not be empty",
112        ));
113    }
114    if spec.fix.is_some() {
115        return Err(Error::rule_config(
116            &spec.id,
117            "file_ends_with has no fix op — pair with an explicit `file_append` rule if you \
118             want auto-append (avoids silently duplicating near-matching suffixes).",
119        ));
120    }
121    Ok(Box::new(FileEndsWithRule {
122        id: spec.id.clone(),
123        level: spec.level,
124        policy_url: spec.policy_url.clone(),
125        message: spec.message.clone(),
126        scope: Scope::from_spec(spec)?,
127        suffix: opts.suffix.into_bytes(),
128    }))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
135
136    #[test]
137    fn build_rejects_missing_paths_field() {
138        let spec = spec_yaml(
139            "id: t\n\
140             kind: file_ends_with\n\
141             suffix: \"\\n\"\n\
142             level: error\n",
143        );
144        assert!(build(&spec).is_err());
145    }
146
147    #[test]
148    fn build_rejects_empty_suffix() {
149        let spec = spec_yaml(
150            "id: t\n\
151             kind: file_ends_with\n\
152             paths: \"**/*\"\n\
153             suffix: \"\"\n\
154             level: error\n",
155        );
156        let err = build(&spec).unwrap_err().to_string();
157        assert!(err.contains("empty"), "unexpected: {err}");
158    }
159
160    #[test]
161    fn build_rejects_fix_block() {
162        let spec = spec_yaml(
163            "id: t\n\
164             kind: file_ends_with\n\
165             paths: \"**/*\"\n\
166             suffix: \"\\n\"\n\
167             level: error\n\
168             fix:\n  \
169               file_append:\n    \
170                 content: \"x\"\n",
171        );
172        assert!(build(&spec).is_err());
173    }
174
175    #[test]
176    fn evaluate_passes_when_suffix_matches() {
177        let spec = spec_yaml(
178            "id: t\n\
179             kind: file_ends_with\n\
180             paths: \"**/*.txt\"\n\
181             suffix: \"END\\n\"\n\
182             level: error\n",
183        );
184        let rule = build(&spec).unwrap();
185        let (tmp, idx) = tempdir_with_files(&[("a.txt", b"hello\nEND\n")]);
186        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
187        assert!(v.is_empty());
188    }
189
190    #[test]
191    fn evaluate_fires_when_suffix_missing() {
192        let spec = spec_yaml(
193            "id: t\n\
194             kind: file_ends_with\n\
195             paths: \"**/*.txt\"\n\
196             suffix: \"END\\n\"\n\
197             level: error\n",
198        );
199        let rule = build(&spec).unwrap();
200        let (tmp, idx) = tempdir_with_files(&[("a.txt", b"hello\n")]);
201        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
202        assert_eq!(v.len(), 1);
203    }
204}