alint_rules/
file_ends_with.rs1use 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 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 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}