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