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