Skip to main content

stern4rust/rules/source/
header_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::reporting::offence::Offence;
6use crate::reporting::rule_explanation::RuleExplanation;
7use crate::rule::Rule;
8use crate::source_file::SourceFile;
9
10// Every .rs file opens with the repository's header.
11//
12// The expected text is data rather than a constant, because it is not the same
13// twice: this repository is MIT, the etheram repositories are Apache 2.0, the
14// year moves, and another codebase will have something else entirely. A rule
15// that hardcoded one header would be right for exactly one repository.
16//
17// Only the first divergence is reported. A file whose header is missing
18// altogether would otherwise produce one offence per header line, burying every
19// other file in the report behind it.
20pub struct HeaderRule {
21    expected: Vec<String>,
22}
23
24impl HeaderRule {
25    pub const NAME: &'static str = "header";
26
27    pub fn new(expected: Vec<String>) -> Self {
28        Self { expected }
29    }
30
31    pub fn expected(&self) -> &[String] {
32        &self.expected
33    }
34
35    // The same instruction for all three failures, because the fix is the same
36    // one: whatever is at the top of the file now, the header replaces it. The
37    // whole expected text travels on the offence, so the correction does not
38    // have to repeat it -- and a consumer applies it in one pass rather than
39    // fixing one line, re-running, and finding the next.
40    fn correction(&self) -> String {
41        format!(
42            "make the first {} lines of the file match the expected header",
43            self.expected.len()
44        )
45    }
46
47    fn expected_text(&self) -> String {
48        self.expected.join("\n")
49    }
50
51    fn missing_entirely(&self, file: &SourceFile) -> Offence {
52        Offence::new(
53            file.relative_path(),
54            1,
55            self.name(),
56            "file is empty, so it carries no header".to_string(),
57            self.correction(),
58        )
59        .with_expected(&self.expected_text())
60    }
61
62    fn ends_early(&self, file: &SourceFile) -> Offence {
63        Offence::new(
64            file.relative_path(),
65            file.lines().len(),
66            self.name(),
67            format!(
68                "file has {} lines but the header is {}",
69                file.lines().len(),
70                self.expected.len()
71            ),
72            self.correction(),
73        )
74        .with_expected(&self.expected_text())
75    }
76
77    fn line_differs(&self, file: &SourceFile, index: usize) -> Offence {
78        Offence::new(
79            file.relative_path(),
80            index + 1,
81            self.name(),
82            format!(
83                "expected {:?} but found {:?}",
84                self.expected[index],
85                file.lines()[index]
86            ),
87            self.correction(),
88        )
89        .with_expected(&self.expected_text())
90    }
91}
92
93impl Rule for HeaderRule {
94    fn name(&self) -> &'static str {
95        Self::NAME
96    }
97
98    // The one rule that can be selected and still have nothing to work from.
99    fn requirement(&self) -> Option<&'static str> {
100        Some("needs --header-file")
101    }
102
103    fn is_configured(&self) -> bool {
104        !self.expected.is_empty()
105    }
106
107    // The overlap is compared before the length, so a file whose very first line
108    // is wrong is reported at line 1 rather than at its end. Checking length
109    // first would tell a file with no header at all that it was "too short",
110    // which is true and useless -- the actionable fact is that line 1 is not the
111    // header.
112    fn check(&self, file: &SourceFile) -> Vec<Offence> {
113        if self.expected.is_empty() {
114            return Vec::new();
115        }
116        if file.is_empty() {
117            return vec![self.missing_entirely(file)];
118        }
119        let overlap = self.expected.len().min(file.lines().len());
120        if let Some(index) =
121            (0..overlap).find(|index| file.lines()[*index] != self.expected[*index])
122        {
123            return vec![self.line_differs(file, index)];
124        }
125        if file.lines().len() < self.expected.len() {
126            return vec![self.ends_early(file)];
127        }
128        Vec::new()
129    }
130
131    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
132        Vec::new()
133    }
134
135    fn explanation(&self) -> RuleExplanation {
136        RuleExplanation::new(
137            self.name(),
138            "Every .rs file opens with the repository's header.",
139            "pub struct Widget;",
140            "// Copyright 2025 ...
141// SPDX-License-Identifier: MIT
142
143pub struct Widget;",
144        )
145    }
146}