alint_rules/
file_header.rs1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
4use regex::Regex;
5use serde::Deserialize;
6
7#[derive(Debug, Deserialize)]
8struct Options {
9 pattern: String,
10 #[serde(default = "default_lines")]
11 lines: usize,
12}
13
14fn default_lines() -> usize {
15 20
16}
17
18#[derive(Debug)]
19pub struct FileHeaderRule {
20 id: String,
21 level: Level,
22 policy_url: Option<String>,
23 message: Option<String>,
24 scope: Scope,
25 pattern_src: String,
26 pattern: Regex,
27 lines: usize,
28}
29
30impl Rule for FileHeaderRule {
31 fn id(&self) -> &str {
32 &self.id
33 }
34 fn level(&self) -> Level {
35 self.level
36 }
37 fn policy_url(&self) -> Option<&str> {
38 self.policy_url.as_deref()
39 }
40
41 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
42 let mut violations = Vec::new();
43 for entry in ctx.index.files() {
44 if !self.scope.matches(&entry.path) {
45 continue;
46 }
47 let full = ctx.root.join(&entry.path);
48 let bytes = match std::fs::read(&full) {
49 Ok(b) => b,
50 Err(e) => {
51 violations.push(
52 Violation::new(format!("could not read file: {e}")).with_path(&entry.path),
53 );
54 continue;
55 }
56 };
57 let Ok(text) = std::str::from_utf8(&bytes) else {
58 violations.push(
59 Violation::new("file is not valid UTF-8; cannot match header")
60 .with_path(&entry.path),
61 );
62 continue;
63 };
64 let header: String = text.split_inclusive('\n').take(self.lines).collect();
65 if !self.pattern.is_match(&header) {
66 let msg = self.message.clone().unwrap_or_else(|| {
67 format!(
68 "first {} line(s) do not match required header /{}/",
69 self.lines, self.pattern_src
70 )
71 });
72 violations.push(
73 Violation::new(msg)
74 .with_path(&entry.path)
75 .with_location(1, 1),
76 );
77 }
78 }
79 Ok(violations)
80 }
81}
82
83pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
84 let Some(paths) = &spec.paths else {
85 return Err(Error::rule_config(
86 &spec.id,
87 "file_header requires a `paths` field",
88 ));
89 };
90 let opts: Options = spec
91 .deserialize_options()
92 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
93 if opts.lines == 0 {
94 return Err(Error::rule_config(
95 &spec.id,
96 "file_header `lines` must be > 0",
97 ));
98 }
99 let pattern = Regex::new(&opts.pattern)
100 .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
101 Ok(Box::new(FileHeaderRule {
102 id: spec.id.clone(),
103 level: spec.level,
104 policy_url: spec.policy_url.clone(),
105 message: spec.message.clone(),
106 scope: Scope::from_paths_spec(paths)?,
107 pattern_src: opts.pattern,
108 pattern,
109 lines: opts.lines,
110 }))
111}