Skip to main content

alint_rules/
file_header.rs

1//! `file_header` — first N lines of each file in scope must match a pattern.
2
3use 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        // Pattern only on line 30, but `lines: 5` only looks at
234        // lines 1-5 → rule fires.
235        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}