alint_rules/
file_footer.rs1use std::path::Path;
18
19use alint_core::{
20 Context, Error, FixSpec, Fixer, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation,
21};
22use regex::Regex;
23use serde::Deserialize;
24
25use crate::fixers::FileAppendFixer;
26
27#[derive(Debug, Deserialize)]
28struct Options {
29 pattern: String,
30 #[serde(default = "default_lines")]
31 lines: usize,
32}
33
34fn default_lines() -> usize {
35 20
36}
37
38#[derive(Debug)]
39pub struct FileFooterRule {
40 id: String,
41 level: Level,
42 policy_url: Option<String>,
43 message: Option<String>,
44 scope: Scope,
45 pattern_src: String,
46 pattern: Regex,
47 lines: usize,
48 fixer: Option<FileAppendFixer>,
49}
50
51impl Rule for FileFooterRule {
52 fn id(&self) -> &str {
53 &self.id
54 }
55 fn level(&self) -> Level {
56 self.level
57 }
58 fn policy_url(&self) -> Option<&str> {
59 self.policy_url.as_deref()
60 }
61
62 fn fixer(&self) -> Option<&dyn Fixer> {
63 self.fixer.as_ref().map(|f| f as &dyn Fixer)
64 }
65
66 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
67 let mut violations = Vec::new();
68 for entry in ctx.index.files() {
69 if !self.scope.matches(&entry.path, ctx.index) {
70 continue;
71 }
72 let full = ctx.root.join(&entry.path);
73 let bytes = match std::fs::read(&full) {
74 Ok(b) => b,
75 Err(e) => {
76 violations.push(
77 Violation::new(format!("could not read file: {e}"))
78 .with_path(entry.path.clone()),
79 );
80 continue;
81 }
82 };
83 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
84 }
85 Ok(violations)
86 }
87
88 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
89 Some(self)
90 }
91}
92
93impl PerFileRule for FileFooterRule {
94 fn path_scope(&self) -> &Scope {
95 &self.scope
96 }
97
98 fn evaluate_file(
99 &self,
100 _ctx: &Context<'_>,
101 path: &Path,
102 bytes: &[u8],
103 ) -> Result<Vec<Violation>> {
104 let Ok(text) = std::str::from_utf8(bytes) else {
105 return Ok(vec![
106 Violation::new("file is not valid UTF-8; cannot match footer")
107 .with_path(std::sync::Arc::<Path>::from(path)),
108 ]);
109 };
110 let footer = last_lines(text, self.lines);
111 if self.pattern.is_match(&footer) {
112 return Ok(Vec::new());
113 }
114 let msg = self.message.clone().unwrap_or_else(|| {
115 format!(
116 "last {} line(s) do not match required footer /{}/",
117 self.lines, self.pattern_src
118 )
119 });
120 Ok(vec![
121 Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
122 ])
123 }
124}
125
126fn last_lines(text: &str, n: usize) -> String {
136 if n == 0 || text.is_empty() {
137 return String::new();
138 }
139 let lines: Vec<&str> = text.split_inclusive('\n').collect();
142 let take = lines.len().min(n);
143 let start = lines.len() - take;
144 lines[start..].concat()
145}
146
147pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
148 let Some(_paths) = &spec.paths else {
149 return Err(Error::rule_config(
150 &spec.id,
151 "file_footer requires a `paths` field",
152 ));
153 };
154 let opts: Options = spec
155 .deserialize_options()
156 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
157 if opts.lines == 0 {
158 return Err(Error::rule_config(
159 &spec.id,
160 "file_footer `lines` must be > 0",
161 ));
162 }
163 let pattern = Regex::new(&opts.pattern)
164 .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
165 let fixer = match &spec.fix {
166 Some(FixSpec::FileAppend { file_append }) => {
167 let source = alint_core::resolve_content_source(
168 &spec.id,
169 "file_append",
170 &file_append.content,
171 &file_append.content_from,
172 )?;
173 Some(FileAppendFixer::new(source))
174 }
175 Some(other) => {
176 return Err(Error::rule_config(
177 &spec.id,
178 format!("fix.{} is not compatible with file_footer", other.op_name()),
179 ));
180 }
181 None => None,
182 };
183 Ok(Box::new(FileFooterRule {
184 id: spec.id.clone(),
185 level: spec.level,
186 policy_url: spec.policy_url.clone(),
187 message: spec.message.clone(),
188 scope: Scope::from_spec(spec)?,
189 pattern_src: opts.pattern,
190 pattern,
191 lines: opts.lines,
192 fixer,
193 }))
194}
195
196#[cfg(test)]
197mod tests {
198 use super::last_lines;
199
200 #[test]
201 fn empty_file_returns_empty() {
202 assert_eq!(last_lines("", 5), "");
203 }
204
205 #[test]
206 fn short_file_returns_whole_thing() {
207 assert_eq!(last_lines("a\nb\n", 5), "a\nb\n");
209 }
210
211 #[test]
212 fn returns_trailing_n_lines() {
213 let body = "1\n2\n3\n4\n5\n";
214 assert_eq!(last_lines(body, 2), "4\n5\n");
215 assert_eq!(last_lines(body, 3), "3\n4\n5\n");
216 }
217
218 #[test]
219 fn unterminated_last_line_carries_through() {
220 assert_eq!(last_lines("a\nb\nc", 1), "c");
222 assert_eq!(last_lines("a\nb\nc", 2), "b\nc");
223 }
224}