Skip to main content

alint_rules/
no_empty_files.rs

1//! `no_empty_files` — flag zero-byte files in scope.
2//!
3//! Empty files usually indicate placeholders forgotten in a
4//! branch or generator output that lost its content. Fixable
5//! via `file_remove`, which deletes the empty file.
6
7use alint_core::{
8    Context, Error, FixSpec, Fixer, Level, Result, Rule, RuleSpec, Scope, ScopeFilter, Violation,
9};
10
11use crate::fixers::FileRemoveFixer;
12
13#[derive(Debug)]
14pub struct NoEmptyFilesRule {
15    id: String,
16    level: Level,
17    policy_url: Option<String>,
18    message: Option<String>,
19    scope: Scope,
20    scope_filter: Option<ScopeFilter>,
21    fixer: Option<FileRemoveFixer>,
22}
23
24impl Rule for NoEmptyFilesRule {
25    fn id(&self) -> &str {
26        &self.id
27    }
28    fn level(&self) -> Level {
29        self.level
30    }
31    fn policy_url(&self) -> Option<&str> {
32        self.policy_url.as_deref()
33    }
34
35    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
36        let mut violations = Vec::new();
37        for entry in ctx.index.files() {
38            if !self.scope.matches(&entry.path) {
39                continue;
40            }
41            if let Some(filter) = &self.scope_filter
42                && !filter.matches(&entry.path, ctx.index)
43            {
44                continue;
45            }
46            if entry.size == 0 {
47                let msg = self
48                    .message
49                    .clone()
50                    .unwrap_or_else(|| "file is empty".to_string());
51                violations.push(Violation::new(msg).with_path(entry.path.clone()));
52            }
53        }
54        Ok(violations)
55    }
56
57    fn fixer(&self) -> Option<&dyn Fixer> {
58        self.fixer.as_ref().map(|f| f as &dyn Fixer)
59    }
60
61    fn scope_filter(&self) -> Option<&ScopeFilter> {
62        self.scope_filter.as_ref()
63    }
64}
65
66pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
67    let paths = spec
68        .paths
69        .as_ref()
70        .ok_or_else(|| Error::rule_config(&spec.id, "no_empty_files requires a `paths` field"))?;
71    let fixer = match &spec.fix {
72        Some(FixSpec::FileRemove { .. }) => Some(FileRemoveFixer),
73        Some(other) => {
74            return Err(Error::rule_config(
75                &spec.id,
76                format!(
77                    "fix.{} is not compatible with no_empty_files",
78                    other.op_name()
79                ),
80            ));
81        }
82        None => None,
83    };
84    Ok(Box::new(NoEmptyFilesRule {
85        id: spec.id.clone(),
86        level: spec.level,
87        policy_url: spec.policy_url.clone(),
88        message: spec.message.clone(),
89        scope: Scope::from_paths_spec(paths)?,
90        scope_filter: spec.parse_scope_filter()?,
91        fixer,
92    }))
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::test_support::{ctx, spec_yaml};
99    use alint_core::{FileEntry, FileIndex};
100    use std::path::Path;
101
102    fn idx(entries: &[(&str, u64)]) -> FileIndex {
103        FileIndex::from_entries(
104            entries
105                .iter()
106                .map(|(p, sz)| FileEntry {
107                    path: std::path::Path::new(p).into(),
108                    is_dir: false,
109                    size: *sz,
110                })
111                .collect(),
112        )
113    }
114
115    #[test]
116    fn build_rejects_missing_paths_field() {
117        let spec = spec_yaml(
118            "id: t\n\
119             kind: no_empty_files\n\
120             level: warning\n",
121        );
122        assert!(build(&spec).is_err());
123    }
124
125    #[test]
126    fn build_accepts_file_remove_fix() {
127        let spec = spec_yaml(
128            "id: t\n\
129             kind: no_empty_files\n\
130             paths: \"**/*\"\n\
131             level: warning\n\
132             fix:\n  \
133               file_remove: {}\n",
134        );
135        let rule = build(&spec).unwrap();
136        assert!(rule.fixer().is_some());
137    }
138
139    #[test]
140    fn build_rejects_incompatible_fix() {
141        let spec = spec_yaml(
142            "id: t\n\
143             kind: no_empty_files\n\
144             paths: \"**/*\"\n\
145             level: warning\n\
146             fix:\n  \
147               file_create:\n    \
148                 content: \"x\"\n",
149        );
150        assert!(build(&spec).is_err());
151    }
152
153    #[test]
154    fn evaluate_fires_on_zero_byte_files() {
155        let spec = spec_yaml(
156            "id: t\n\
157             kind: no_empty_files\n\
158             paths: \"**/*\"\n\
159             level: warning\n",
160        );
161        let rule = build(&spec).unwrap();
162        let i = idx(&[("a.txt", 0), ("b.txt", 100), ("c.txt", 0)]);
163        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
164        assert_eq!(v.len(), 2, "two empty files should fire");
165    }
166
167    #[test]
168    fn evaluate_passes_on_non_empty_files() {
169        let spec = spec_yaml(
170            "id: t\n\
171             kind: no_empty_files\n\
172             paths: \"**/*\"\n\
173             level: warning\n",
174        );
175        let rule = build(&spec).unwrap();
176        let i = idx(&[("a.txt", 1), ("b.txt", 1024)]);
177        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
178        assert!(v.is_empty());
179    }
180
181    #[test]
182    fn scope_filter_narrows() {
183        // Two empty files; only the one inside a directory with
184        // `marker.lock` as ancestor should fire.
185        let spec = spec_yaml(
186            "id: t\n\
187             kind: no_empty_files\n\
188             paths: \"**/*.txt\"\n\
189             scope_filter:\n  \
190               has_ancestor: marker.lock\n\
191             level: warning\n",
192        );
193        let rule = build(&spec).unwrap();
194        let i = idx(&[
195            ("pkg/marker.lock", 1),
196            ("pkg/empty.txt", 0),
197            ("other/empty.txt", 0),
198        ]);
199        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
200        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
201        assert_eq!(v[0].path.as_deref(), Some(Path::new("pkg/empty.txt")));
202    }
203}