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}"))
74                            .with_path(entry.path.clone()),
75                    );
76                    continue;
77                }
78            };
79            let Ok(text) = std::str::from_utf8(&bytes) else {
80                violations.push(
81                    Violation::new("file is not valid UTF-8; cannot match footer")
82                        .with_path(entry.path.clone()),
83                );
84                continue;
85            };
86            let footer = last_lines(text, self.lines);
87            if !self.pattern.is_match(&footer) {
88                let msg = self.message.clone().unwrap_or_else(|| {
89                    format!(
90                        "last {} line(s) do not match required footer /{}/",
91                        self.lines, self.pattern_src
92                    )
93                });
94                violations.push(Violation::new(msg).with_path(entry.path.clone()));
95            }
96        }
97        Ok(violations)
98    }
99}
100
101/// Return the last `n` lines of `text` as a single string,
102/// preserving the line terminators that were already there.
103/// Empty files return the empty string. Files shorter than `n`
104/// lines return the entire file.
105///
106/// We split on `\n`, take the trailing `n` slices, then re-join
107/// with `\n` so the result reads identically to what
108/// `file_header`'s `take(N)` would produce on a flipped
109/// document.
110fn last_lines(text: &str, n: usize) -> String {
111    if n == 0 || text.is_empty() {
112        return String::new();
113    }
114    // `split_inclusive` keeps the `\n` on every line except
115    // possibly the last, mirroring `file_header`'s parser.
116    let lines: Vec<&str> = text.split_inclusive('\n').collect();
117    let take = lines.len().min(n);
118    let start = lines.len() - take;
119    lines[start..].concat()
120}
121
122pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
123    let Some(paths) = &spec.paths else {
124        return Err(Error::rule_config(
125            &spec.id,
126            "file_footer requires a `paths` field",
127        ));
128    };
129    let opts: Options = spec
130        .deserialize_options()
131        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
132    if opts.lines == 0 {
133        return Err(Error::rule_config(
134            &spec.id,
135            "file_footer `lines` must be > 0",
136        ));
137    }
138    let pattern = Regex::new(&opts.pattern)
139        .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
140    let fixer = match &spec.fix {
141        Some(FixSpec::FileAppend { file_append }) => {
142            let source = alint_core::resolve_content_source(
143                &spec.id,
144                "file_append",
145                &file_append.content,
146                &file_append.content_from,
147            )?;
148            Some(FileAppendFixer::new(source))
149        }
150        Some(other) => {
151            return Err(Error::rule_config(
152                &spec.id,
153                format!("fix.{} is not compatible with file_footer", other.op_name()),
154            ));
155        }
156        None => None,
157    };
158    Ok(Box::new(FileFooterRule {
159        id: spec.id.clone(),
160        level: spec.level,
161        policy_url: spec.policy_url.clone(),
162        message: spec.message.clone(),
163        scope: Scope::from_paths_spec(paths)?,
164        pattern_src: opts.pattern,
165        pattern,
166        lines: opts.lines,
167        fixer,
168    }))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::last_lines;
174
175    #[test]
176    fn empty_file_returns_empty() {
177        assert_eq!(last_lines("", 5), "");
178    }
179
180    #[test]
181    fn short_file_returns_whole_thing() {
182        // 2 lines, asked for 5 → return both.
183        assert_eq!(last_lines("a\nb\n", 5), "a\nb\n");
184    }
185
186    #[test]
187    fn returns_trailing_n_lines() {
188        let body = "1\n2\n3\n4\n5\n";
189        assert_eq!(last_lines(body, 2), "4\n5\n");
190        assert_eq!(last_lines(body, 3), "3\n4\n5\n");
191    }
192
193    #[test]
194    fn unterminated_last_line_carries_through() {
195        // No trailing newline; "c" is the last line.
196        assert_eq!(last_lines("a\nb\nc", 1), "c");
197        assert_eq!(last_lines("a\nb\nc", 2), "b\nc");
198    }
199}