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