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