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