Skip to main content

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