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)]
28struct Options {
29    pattern: String,
30    #[serde(default = "default_lines")]
31    lines: usize,
32}
33
34fn default_lines() -> usize {
35    20
36}
37
38#[derive(Debug)]
39pub struct FileFooterRule {
40    id: String,
41    level: Level,
42    policy_url: Option<String>,
43    message: Option<String>,
44    scope: Scope,
45    pattern_src: String,
46    pattern: Regex,
47    lines: usize,
48    fixer: Option<FileAppendFixer>,
49}
50
51impl Rule for FileFooterRule {
52    fn id(&self) -> &str {
53        &self.id
54    }
55    fn level(&self) -> Level {
56        self.level
57    }
58    fn policy_url(&self) -> Option<&str> {
59        self.policy_url.as_deref()
60    }
61
62    fn fixer(&self) -> Option<&dyn Fixer> {
63        self.fixer.as_ref().map(|f| f as &dyn Fixer)
64    }
65
66    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
67        let mut violations = Vec::new();
68        for entry in ctx.index.files() {
69            if !self.scope.matches(&entry.path, ctx.index) {
70                continue;
71            }
72            let full = ctx.root.join(&entry.path);
73            let bytes = match std::fs::read(&full) {
74                Ok(b) => b,
75                Err(e) => {
76                    violations.push(
77                        Violation::new(format!("could not read file: {e}"))
78                            .with_path(entry.path.clone()),
79                    );
80                    continue;
81                }
82            };
83            violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
84        }
85        Ok(violations)
86    }
87
88    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
89        Some(self)
90    }
91}
92
93impl PerFileRule for FileFooterRule {
94    fn path_scope(&self) -> &Scope {
95        &self.scope
96    }
97
98    fn evaluate_file(
99        &self,
100        _ctx: &Context<'_>,
101        path: &Path,
102        bytes: &[u8],
103    ) -> Result<Vec<Violation>> {
104        let Ok(text) = std::str::from_utf8(bytes) else {
105            return Ok(vec![
106                Violation::new("file is not valid UTF-8; cannot match footer")
107                    .with_path(std::sync::Arc::<Path>::from(path)),
108            ]);
109        };
110        let footer = last_lines(text, self.lines);
111        if self.pattern.is_match(&footer) {
112            return Ok(Vec::new());
113        }
114        let msg = self.message.clone().unwrap_or_else(|| {
115            format!(
116                "last {} line(s) do not match required footer /{}/",
117                self.lines, self.pattern_src
118            )
119        });
120        Ok(vec![
121            Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
122        ])
123    }
124}
125
126/// Return the last `n` lines of `text` as a single string,
127/// preserving the line terminators that were already there.
128/// Empty files return the empty string. Files shorter than `n`
129/// lines return the entire file.
130///
131/// We split on `\n`, take the trailing `n` slices, then re-join
132/// with `\n` so the result reads identically to what
133/// `file_header`'s `take(N)` would produce on a flipped
134/// document.
135fn last_lines(text: &str, n: usize) -> String {
136    if n == 0 || text.is_empty() {
137        return String::new();
138    }
139    // `split_inclusive` keeps the `\n` on every line except
140    // possibly the last, mirroring `file_header`'s parser.
141    let lines: Vec<&str> = text.split_inclusive('\n').collect();
142    let take = lines.len().min(n);
143    let start = lines.len() - take;
144    lines[start..].concat()
145}
146
147pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
148    let Some(_paths) = &spec.paths else {
149        return Err(Error::rule_config(
150            &spec.id,
151            "file_footer requires a `paths` field",
152        ));
153    };
154    let opts: Options = spec
155        .deserialize_options()
156        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
157    if opts.lines == 0 {
158        return Err(Error::rule_config(
159            &spec.id,
160            "file_footer `lines` must be > 0",
161        ));
162    }
163    let pattern = Regex::new(&opts.pattern)
164        .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
165    let fixer = match &spec.fix {
166        Some(FixSpec::FileAppend { file_append }) => {
167            let source = alint_core::resolve_content_source(
168                &spec.id,
169                "file_append",
170                &file_append.content,
171                &file_append.content_from,
172            )?;
173            Some(FileAppendFixer::new(source))
174        }
175        Some(other) => {
176            return Err(Error::rule_config(
177                &spec.id,
178                format!("fix.{} is not compatible with file_footer", other.op_name()),
179            ));
180        }
181        None => None,
182    };
183    Ok(Box::new(FileFooterRule {
184        id: spec.id.clone(),
185        level: spec.level,
186        policy_url: spec.policy_url.clone(),
187        message: spec.message.clone(),
188        scope: Scope::from_spec(spec)?,
189        pattern_src: opts.pattern,
190        pattern,
191        lines: opts.lines,
192        fixer,
193    }))
194}
195
196#[cfg(test)]
197mod tests {
198    use super::last_lines;
199
200    #[test]
201    fn empty_file_returns_empty() {
202        assert_eq!(last_lines("", 5), "");
203    }
204
205    #[test]
206    fn short_file_returns_whole_thing() {
207        // 2 lines, asked for 5 → return both.
208        assert_eq!(last_lines("a\nb\n", 5), "a\nb\n");
209    }
210
211    #[test]
212    fn returns_trailing_n_lines() {
213        let body = "1\n2\n3\n4\n5\n";
214        assert_eq!(last_lines(body, 2), "4\n5\n");
215        assert_eq!(last_lines(body, 3), "3\n4\n5\n");
216    }
217
218    #[test]
219    fn unterminated_last_line_carries_through() {
220        // No trailing newline; "c" is the last line.
221        assert_eq!(last_lines("a\nb\nc", 1), "c");
222        assert_eq!(last_lines("a\nb\nc", 2), "b\nc");
223    }
224}