Skip to main content

alint_rules/
file_footer.rs

1//! `file_footer` — last N lines of each file in scope must match a pattern.
2//!
3//! Mirror of [`crate::file_header`], anchored at the END of the
4//! file. Use cases:
5//!
6//! - License footers ("Licensed under the Apache License…")
7//! - Generated-file trailers ("DO NOT EDIT — regenerate via …")
8//! - Signed-off-by trailers
9//! - Final blank-line + sentinel patterns
10//!
11//! Same `pattern:` + `lines:` shape as `file_header` so configs
12//! that mix the two read symmetrically. The fix op is
13//! `file_append`: when the rule fires and a fixer is attached,
14//! the configured content is appended to the file (inverse of
15//! `file_header` + `file_prepend`).
16
17use std::path::Path;
18
19use alint_core::{
20    Context, Error, FixSpec, Fixer, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation,
21};
22use regex::Regex;
23use serde::Deserialize;
24
25use crate::fixers::FileAppendFixer;
26
27#[derive(Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29struct Options {
30    pattern: String,
31    #[serde(default = "default_lines")]
32    lines: usize,
33}
34
35fn default_lines() -> usize {
36    20
37}
38
39#[derive(Debug)]
40pub struct FileFooterRule {
41    id: String,
42    level: Level,
43    policy_url: Option<String>,
44    message: Option<String>,
45    scope: Scope,
46    pattern_src: String,
47    pattern: Regex,
48    lines: usize,
49    fixer: Option<FileAppendFixer>,
50}
51
52impl Rule for FileFooterRule {
53    alint_core::rule_common_impl!();
54
55    fn fixer(&self) -> Option<&dyn Fixer> {
56        self.fixer.as_ref().map(|f| f as &dyn Fixer)
57    }
58
59    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
60        let mut violations = Vec::new();
61        for entry in ctx.index.files() {
62            if !self.scope.matches(&entry.path, ctx.index) {
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
86impl PerFileRule for FileFooterRule {
87    fn path_scope(&self) -> &Scope {
88        &self.scope
89    }
90
91    fn evaluate_file(
92        &self,
93        _ctx: &Context<'_>,
94        path: &Path,
95        bytes: &[u8],
96    ) -> Result<Vec<Violation>> {
97        let Ok(text) = std::str::from_utf8(bytes) else {
98            return Ok(vec![
99                Violation::new("file is not valid UTF-8; cannot match footer")
100                    .with_path(std::sync::Arc::<Path>::from(path)),
101            ]);
102        };
103        let footer = last_lines(text, self.lines);
104        if self.pattern.is_match(&footer) {
105            return Ok(Vec::new());
106        }
107        let msg = self.message.clone().unwrap_or_else(|| {
108            format!(
109                "last {} line(s) do not match required footer /{}/",
110                self.lines, self.pattern_src
111            )
112        });
113        Ok(vec![
114            Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
115        ])
116    }
117}
118
119/// Return the last `n` lines of `text` as a single string,
120/// preserving the line terminators that were already there.
121/// Empty files return the empty string. Files shorter than `n`
122/// lines return the entire file.
123///
124/// We split on `\n`, take the trailing `n` slices, then re-join
125/// with `\n` so the result reads identically to what
126/// `file_header`'s `take(N)` would produce on a flipped
127/// document.
128fn last_lines(text: &str, n: usize) -> String {
129    if n == 0 || text.is_empty() {
130        return String::new();
131    }
132    // `split_inclusive` keeps the `\n` on every line except
133    // possibly the last, mirroring `file_header`'s parser.
134    let lines: Vec<&str> = text.split_inclusive('\n').collect();
135    let take = lines.len().min(n);
136    let start = lines.len() - take;
137    lines[start..].concat()
138}
139
140pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
141    let Some(_paths) = &spec.paths else {
142        return Err(Error::rule_config(
143            &spec.id,
144            "file_footer requires a `paths` field",
145        ));
146    };
147    let opts: Options = spec
148        .deserialize_options()
149        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
150    if opts.lines == 0 {
151        return Err(Error::rule_config(
152            &spec.id,
153            "file_footer `lines` must be > 0",
154        ));
155    }
156    let pattern = Regex::new(&opts.pattern)
157        .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
158    let fixer = match &spec.fix {
159        Some(FixSpec::FileAppend { file_append }) => {
160            let source = alint_core::resolve_content_source(
161                &spec.id,
162                "file_append",
163                &file_append.content,
164                &file_append.content_from,
165            )?;
166            Some(FileAppendFixer::new(source))
167        }
168        Some(other) => {
169            return Err(Error::rule_config(
170                &spec.id,
171                format!("fix.{} is not compatible with file_footer", other.op_name()),
172            ));
173        }
174        None => None,
175    };
176    Ok(Box::new(FileFooterRule {
177        id: spec.id.clone(),
178        level: spec.level,
179        policy_url: spec.policy_url.clone(),
180        message: spec.message.clone(),
181        scope: Scope::from_spec(spec)?,
182        pattern_src: opts.pattern,
183        pattern,
184        lines: opts.lines,
185        fixer,
186    }))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::last_lines;
192
193    #[test]
194    fn empty_file_returns_empty() {
195        assert_eq!(last_lines("", 5), "");
196    }
197
198    #[test]
199    fn short_file_returns_whole_thing() {
200        // 2 lines, asked for 5 → return both.
201        assert_eq!(last_lines("a\nb\n", 5), "a\nb\n");
202    }
203
204    #[test]
205    fn returns_trailing_n_lines() {
206        let body = "1\n2\n3\n4\n5\n";
207        assert_eq!(last_lines(body, 2), "4\n5\n");
208        assert_eq!(last_lines(body, 3), "3\n4\n5\n");
209    }
210
211    #[test]
212    fn unterminated_last_line_carries_through() {
213        // No trailing newline; "c" is the last line.
214        assert_eq!(last_lines("a\nb\nc", 1), "c");
215        assert_eq!(last_lines("a\nb\nc", 2), "b\nc");
216    }
217}