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}")).with_path(&entry.path),
74 );
75 continue;
76 }
77 };
78 let Ok(text) = std::str::from_utf8(&bytes) else {
79 violations.push(
80 Violation::new("file is not valid UTF-8; cannot match footer")
81 .with_path(&entry.path),
82 );
83 continue;
84 };
85 let footer = last_lines(text, self.lines);
86 if !self.pattern.is_match(&footer) {
87 let msg = self.message.clone().unwrap_or_else(|| {
88 format!(
89 "last {} line(s) do not match required footer /{}/",
90 self.lines, self.pattern_src
91 )
92 });
93 violations.push(Violation::new(msg).with_path(&entry.path));
94 }
95 }
96 Ok(violations)
97 }
98}
99
100fn last_lines(text: &str, n: usize) -> String {
110 if n == 0 || text.is_empty() {
111 return String::new();
112 }
113 let lines: Vec<&str> = text.split_inclusive('\n').collect();
116 let take = lines.len().min(n);
117 let start = lines.len() - take;
118 lines[start..].concat()
119}
120
121pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
122 let Some(paths) = &spec.paths else {
123 return Err(Error::rule_config(
124 &spec.id,
125 "file_footer requires a `paths` field",
126 ));
127 };
128 let opts: Options = spec
129 .deserialize_options()
130 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
131 if opts.lines == 0 {
132 return Err(Error::rule_config(
133 &spec.id,
134 "file_footer `lines` must be > 0",
135 ));
136 }
137 let pattern = Regex::new(&opts.pattern)
138 .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
139 let fixer = match &spec.fix {
140 Some(FixSpec::FileAppend { file_append }) => {
141 let source = alint_core::resolve_content_source(
142 &spec.id,
143 "file_append",
144 &file_append.content,
145 &file_append.content_from,
146 )?;
147 Some(FileAppendFixer::new(source))
148 }
149 Some(other) => {
150 return Err(Error::rule_config(
151 &spec.id,
152 format!("fix.{} is not compatible with file_footer", other.op_name()),
153 ));
154 }
155 None => None,
156 };
157 Ok(Box::new(FileFooterRule {
158 id: spec.id.clone(),
159 level: spec.level,
160 policy_url: spec.policy_url.clone(),
161 message: spec.message.clone(),
162 scope: Scope::from_paths_spec(paths)?,
163 pattern_src: opts.pattern,
164 pattern,
165 lines: opts.lines,
166 fixer,
167 }))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::last_lines;
173
174 #[test]
175 fn empty_file_returns_empty() {
176 assert_eq!(last_lines("", 5), "");
177 }
178
179 #[test]
180 fn short_file_returns_whole_thing() {
181 assert_eq!(last_lines("a\nb\n", 5), "a\nb\n");
183 }
184
185 #[test]
186 fn returns_trailing_n_lines() {
187 let body = "1\n2\n3\n4\n5\n";
188 assert_eq!(last_lines(body, 2), "4\n5\n");
189 assert_eq!(last_lines(body, 3), "3\n4\n5\n");
190 }
191
192 #[test]
193 fn unterminated_last_line_carries_through() {
194 assert_eq!(last_lines("a\nb\nc", 1), "c");
196 assert_eq!(last_lines("a\nb\nc", 2), "b\nc");
197 }
198}