alint_rules/
file_header.rs1use std::path::Path;
4
5use alint_core::{
6 Context, Error, FixSpec, Fixer, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation,
7};
8use regex::Regex;
9use serde::Deserialize;
10
11use crate::fixers::FilePrependFixer;
12
13#[derive(Debug, Deserialize)]
14#[serde(deny_unknown_fields)]
15struct Options {
16 pattern: String,
17 #[serde(default = "default_lines")]
18 lines: usize,
19}
20
21fn default_lines() -> usize {
22 20
23}
24
25#[derive(Debug)]
26pub struct FileHeaderRule {
27 id: String,
28 level: Level,
29 policy_url: Option<String>,
30 message: Option<String>,
31 scope: Scope,
32 pattern_src: String,
33 pattern: Regex,
34 lines: usize,
35 fixer: Option<FilePrependFixer>,
36}
37
38impl Rule for FileHeaderRule {
39 alint_core::rule_common_impl!();
40
41 fn fixer(&self) -> Option<&dyn Fixer> {
42 self.fixer.as_ref().map(|f| f as &dyn Fixer)
43 }
44
45 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
46 let mut violations = Vec::new();
47 for entry in ctx.index.files() {
48 if !self.scope.matches(&entry.path, ctx.index) {
49 continue;
50 }
51 let full = ctx.root.join(&entry.path);
52 let bytes = match std::fs::read(&full) {
53 Ok(b) => b,
54 Err(e) => {
55 violations.push(
56 Violation::new(format!("could not read file: {e}"))
57 .with_path(entry.path.clone()),
58 );
59 continue;
60 }
61 };
62 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
63 }
64 Ok(violations)
65 }
66
67 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
68 Some(self)
69 }
70}
71
72impl PerFileRule for FileHeaderRule {
73 fn path_scope(&self) -> &Scope {
74 &self.scope
75 }
76
77 fn evaluate_file(
78 &self,
79 _ctx: &Context<'_>,
80 path: &Path,
81 bytes: &[u8],
82 ) -> Result<Vec<Violation>> {
83 let Ok(text) = std::str::from_utf8(bytes) else {
84 return Ok(vec![
85 Violation::new("file is not valid UTF-8; cannot match header")
86 .with_path(std::sync::Arc::<Path>::from(path)),
87 ]);
88 };
89 let header: String = text.split_inclusive('\n').take(self.lines).collect();
90 if self.pattern.is_match(&header) {
91 return Ok(Vec::new());
92 }
93 let msg = self.message.clone().unwrap_or_else(|| {
94 format!(
95 "first {} line(s) do not match required header /{}/",
96 self.lines, self.pattern_src
97 )
98 });
99 Ok(vec![
100 Violation::new(msg)
101 .with_path(std::sync::Arc::<Path>::from(path))
102 .with_location(1, 1),
103 ])
104 }
105}
106
107pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
108 let Some(_paths) = &spec.paths else {
109 return Err(Error::rule_config(
110 &spec.id,
111 "file_header requires a `paths` field",
112 ));
113 };
114 let opts: Options = spec
115 .deserialize_options()
116 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
117 if opts.lines == 0 {
118 return Err(Error::rule_config(
119 &spec.id,
120 "file_header `lines` must be > 0",
121 ));
122 }
123 let pattern = Regex::new(&opts.pattern)
124 .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
125 let fixer = match &spec.fix {
126 Some(FixSpec::FilePrepend { file_prepend }) => {
127 let source = alint_core::resolve_content_source(
128 &spec.id,
129 "file_prepend",
130 &file_prepend.content,
131 &file_prepend.content_from,
132 )?;
133 Some(FilePrependFixer::new(source))
134 }
135 Some(other) => {
136 return Err(Error::rule_config(
137 &spec.id,
138 format!("fix.{} is not compatible with file_header", other.op_name()),
139 ));
140 }
141 None => None,
142 };
143 Ok(Box::new(FileHeaderRule {
144 id: spec.id.clone(),
145 level: spec.level,
146 policy_url: spec.policy_url.clone(),
147 message: spec.message.clone(),
148 scope: Scope::from_spec(spec)?,
149 pattern_src: opts.pattern,
150 pattern,
151 lines: opts.lines,
152 fixer,
153 }))
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
160
161 #[test]
162 fn build_rejects_missing_paths_field() {
163 let spec = spec_yaml(
164 "id: t\n\
165 kind: file_header\n\
166 pattern: \"^// SPDX\"\n\
167 level: error\n",
168 );
169 assert!(build(&spec).is_err());
170 }
171
172 #[test]
173 fn build_rejects_zero_lines() {
174 let spec = spec_yaml(
175 "id: t\n\
176 kind: file_header\n\
177 paths: \"src/**/*.rs\"\n\
178 pattern: \"^// SPDX\"\n\
179 lines: 0\n\
180 level: error\n",
181 );
182 let err = build(&spec).unwrap_err().to_string();
183 assert!(err.contains("lines"), "unexpected: {err}");
184 }
185
186 #[test]
187 fn build_rejects_invalid_regex() {
188 let spec = spec_yaml(
189 "id: t\n\
190 kind: file_header\n\
191 paths: \"src/**/*.rs\"\n\
192 pattern: \"[unterminated\"\n\
193 level: error\n",
194 );
195 assert!(build(&spec).is_err());
196 }
197
198 #[test]
199 fn evaluate_passes_when_header_matches() {
200 let spec = spec_yaml(
201 "id: t\n\
202 kind: file_header\n\
203 paths: \"src/**/*.rs\"\n\
204 pattern: \"SPDX-License-Identifier: Apache-2.0\"\n\
205 level: error\n",
206 );
207 let rule = build(&spec).unwrap();
208 let (tmp, idx) = tempdir_with_files(&[(
209 "src/main.rs",
210 b"// SPDX-License-Identifier: Apache-2.0\n\nfn main() {}\n",
211 )]);
212 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
213 assert!(v.is_empty(), "header should match: {v:?}");
214 }
215
216 #[test]
217 fn evaluate_fires_when_header_missing() {
218 let spec = spec_yaml(
219 "id: t\n\
220 kind: file_header\n\
221 paths: \"src/**/*.rs\"\n\
222 pattern: \"SPDX-License-Identifier:\"\n\
223 level: error\n",
224 );
225 let rule = build(&spec).unwrap();
226 let (tmp, idx) = tempdir_with_files(&[("src/main.rs", b"fn main() {}\n")]);
227 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
228 assert_eq!(v.len(), 1);
229 }
230
231 #[test]
232 fn evaluate_only_inspects_first_n_lines() {
233 let spec = spec_yaml(
236 "id: t\n\
237 kind: file_header\n\
238 paths: \"src/**/*.rs\"\n\
239 pattern: \"NEEDLE\"\n\
240 lines: 5\n\
241 level: error\n",
242 );
243 let rule = build(&spec).unwrap();
244 let mut content = String::new();
245 for _ in 0..30 {
246 content.push_str("filler\n");
247 }
248 content.push_str("NEEDLE\n");
249 let (tmp, idx) = tempdir_with_files(&[("src/main.rs", content.as_bytes())]);
250 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
251 assert_eq!(v.len(), 1);
252 }
253}