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