Skip to main content

alint_rules/
file_min_lines.rs

1//! `file_min_lines` — files in scope must have at least
2//! `min_lines` lines.
3//!
4//! Catches the "README is a title plus two sentences" case
5//! where the file exists, isn't empty, but is far too thin to
6//! actually document anything. Pairs well with `file_exists`
7//! on README / CHANGELOG / SECURITY.md in governance rulesets.
8//!
9//! A **line** is any run of bytes terminated by `\n`. The
10//! trailing segment after the last newline (or the whole file
11//! when there is no newline) counts as one additional line
12//! only when it is non-empty — so `"a\nb\n"` and `"a\nb"` both
13//! report 2 lines, while `"a\nb\n\n"` reports 3 (the empty
14//! line between the two newlines counts). This matches the
15//! usual `wc -l` semantics closely enough for policy use;
16//! pedantic counting differences aren't worth the surprise.
17
18use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
19use serde::Deserialize;
20
21#[derive(Debug, Deserialize)]
22struct Options {
23    min_lines: u64,
24}
25
26#[derive(Debug)]
27pub struct FileMinLinesRule {
28    id: String,
29    level: Level,
30    policy_url: Option<String>,
31    message: Option<String>,
32    scope: Scope,
33    min_lines: u64,
34}
35
36impl Rule for FileMinLinesRule {
37    fn id(&self) -> &str {
38        &self.id
39    }
40    fn level(&self) -> Level {
41        self.level
42    }
43    fn policy_url(&self) -> Option<&str> {
44        self.policy_url.as_deref()
45    }
46
47    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
48        let mut violations = Vec::new();
49        for entry in ctx.index.files() {
50            if !self.scope.matches(&entry.path) {
51                continue;
52            }
53            let full = ctx.root.join(&entry.path);
54            let Ok(bytes) = std::fs::read(&full) else {
55                // Unreadable (permission, race with a remove, …)
56                // — mirror the rest of the content-family rules
57                // and skip silently rather than blowing up a
58                // whole check run.
59                continue;
60            };
61            let lines = count_lines(&bytes);
62            if lines < self.min_lines {
63                let msg = self.message.clone().unwrap_or_else(|| {
64                    format!(
65                        "file has {} line(s); at least {} required",
66                        lines, self.min_lines,
67                    )
68                });
69                violations.push(Violation::new(msg).with_path(&entry.path));
70            }
71        }
72        Ok(violations)
73    }
74}
75
76/// Count lines with `wc -l`-style semantics: every `\n` is a
77/// line terminator, plus one more line when the file doesn't
78/// end with `\n` but has content after the last `\n`. Empty
79/// file → 0 lines.
80fn count_lines(bytes: &[u8]) -> u64 {
81    if bytes.is_empty() {
82        return 0;
83    }
84    // `bytecount` would be faster, but line-count files are
85    // typically READMEs / CHANGELOGs (small). Not worth a
86    // dep for a hot loop that isn't.
87    #[allow(clippy::naive_bytecount)]
88    let newlines = bytes.iter().filter(|&&b| b == b'\n').count() as u64;
89    let trailing_unterminated = u64::from(!bytes.ends_with(b"\n"));
90    newlines + trailing_unterminated
91}
92
93pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
94    let Some(paths) = &spec.paths else {
95        return Err(Error::rule_config(
96            &spec.id,
97            "file_min_lines requires a `paths` field",
98        ));
99    };
100    let opts: Options = spec
101        .deserialize_options()
102        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
103    Ok(Box::new(FileMinLinesRule {
104        id: spec.id.clone(),
105        level: spec.level,
106        policy_url: spec.policy_url.clone(),
107        message: spec.message.clone(),
108        scope: Scope::from_paths_spec(paths)?,
109        min_lines: opts.min_lines,
110    }))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::count_lines;
116
117    #[test]
118    fn empty_file_has_zero_lines() {
119        assert_eq!(count_lines(b""), 0);
120    }
121
122    #[test]
123    fn content_with_trailing_newline_counts_each_line() {
124        assert_eq!(count_lines(b"a\n"), 1);
125        assert_eq!(count_lines(b"a\nb\n"), 2);
126        assert_eq!(count_lines(b"a\nb\nc\n"), 3);
127    }
128
129    #[test]
130    fn content_without_trailing_newline_adds_one_for_tail() {
131        assert_eq!(count_lines(b"a"), 1);
132        assert_eq!(count_lines(b"a\nb"), 2);
133    }
134
135    #[test]
136    fn blank_lines_count() {
137        assert_eq!(count_lines(b"a\n\nb\n"), 3);
138        assert_eq!(count_lines(b"\n\n"), 2);
139    }
140}