alint_rules/
file_min_size.rs1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, ScopeFilter, Violation};
15use serde::Deserialize;
16
17#[derive(Debug, Deserialize)]
18struct Options {
19 min_bytes: u64,
20}
21
22#[derive(Debug)]
23pub struct FileMinSizeRule {
24 id: String,
25 level: Level,
26 policy_url: Option<String>,
27 message: Option<String>,
28 scope: Scope,
29 scope_filter: Option<ScopeFilter>,
30 min_bytes: u64,
31}
32
33impl Rule for FileMinSizeRule {
34 fn id(&self) -> &str {
35 &self.id
36 }
37 fn level(&self) -> Level {
38 self.level
39 }
40 fn policy_url(&self) -> Option<&str> {
41 self.policy_url.as_deref()
42 }
43
44 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
45 let mut violations = Vec::new();
46 for entry in ctx.index.files() {
47 if !self.scope.matches(&entry.path) {
48 continue;
49 }
50 if let Some(filter) = &self.scope_filter
51 && !filter.matches(&entry.path, ctx.index)
52 {
53 continue;
54 }
55 if entry.size < self.min_bytes {
56 let msg = self.message.clone().unwrap_or_else(|| {
57 format!(
58 "file below {} byte(s) (actual: {})",
59 self.min_bytes, entry.size,
60 )
61 });
62 violations.push(Violation::new(msg).with_path(entry.path.clone()));
63 }
64 }
65 Ok(violations)
66 }
67
68 fn scope_filter(&self) -> Option<&ScopeFilter> {
69 self.scope_filter.as_ref()
70 }
71}
72
73pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
74 let Some(paths) = &spec.paths else {
75 return Err(Error::rule_config(
76 &spec.id,
77 "file_min_size requires a `paths` field",
78 ));
79 };
80 let opts: Options = spec
81 .deserialize_options()
82 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
83 Ok(Box::new(FileMinSizeRule {
84 id: spec.id.clone(),
85 level: spec.level,
86 policy_url: spec.policy_url.clone(),
87 message: spec.message.clone(),
88 scope: Scope::from_paths_spec(paths)?,
89 scope_filter: spec.parse_scope_filter()?,
90 min_bytes: opts.min_bytes,
91 }))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::test_support::{ctx, spec_yaml};
98 use alint_core::{FileEntry, FileIndex};
99 use std::path::Path;
100
101 fn idx_with_size(path: &str, size: u64) -> FileIndex {
102 FileIndex::from_entries(vec![FileEntry {
103 path: std::path::Path::new(path).into(),
104 is_dir: false,
105 size,
106 }])
107 }
108
109 #[test]
110 fn build_rejects_missing_paths_field() {
111 let spec = spec_yaml(
112 "id: t\n\
113 kind: file_min_size\n\
114 min_bytes: 100\n\
115 level: warning\n",
116 );
117 assert!(build(&spec).is_err());
118 }
119
120 #[test]
121 fn evaluate_passes_when_size_above_minimum() {
122 let spec = spec_yaml(
123 "id: t\n\
124 kind: file_min_size\n\
125 paths: \"README.md\"\n\
126 min_bytes: 100\n\
127 level: warning\n",
128 );
129 let rule = build(&spec).unwrap();
130 let idx = idx_with_size("README.md", 1024);
131 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
132 assert!(v.is_empty());
133 }
134
135 #[test]
136 fn evaluate_fires_when_size_below_minimum() {
137 let spec = spec_yaml(
138 "id: t\n\
139 kind: file_min_size\n\
140 paths: \"README.md\"\n\
141 min_bytes: 100\n\
142 level: warning\n",
143 );
144 let rule = build(&spec).unwrap();
145 let idx = idx_with_size("README.md", 10);
146 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
147 assert_eq!(v.len(), 1);
148 }
149
150 #[test]
151 fn evaluate_size_at_exact_minimum_passes() {
152 let spec = spec_yaml(
155 "id: t\n\
156 kind: file_min_size\n\
157 paths: \"a.bin\"\n\
158 min_bytes: 100\n\
159 level: warning\n",
160 );
161 let rule = build(&spec).unwrap();
162 let idx = idx_with_size("a.bin", 100);
163 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
164 assert!(v.is_empty(), "size == min should pass: {v:?}");
165 }
166
167 #[test]
168 fn evaluate_zero_byte_file_fires_when_minimum_positive() {
169 let spec = spec_yaml(
170 "id: t\n\
171 kind: file_min_size\n\
172 paths: \"empty.txt\"\n\
173 min_bytes: 1\n\
174 level: warning\n",
175 );
176 let rule = build(&spec).unwrap();
177 let idx = idx_with_size("empty.txt", 0);
178 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
179 assert_eq!(v.len(), 1);
180 }
181
182 #[test]
183 fn scope_filter_narrows() {
184 let spec = spec_yaml(
187 "id: t\n\
188 kind: file_min_size\n\
189 paths: \"**/*.txt\"\n\
190 min_bytes: 100\n\
191 scope_filter:\n \
192 has_ancestor: marker.lock\n\
193 level: warning\n",
194 );
195 let rule = build(&spec).unwrap();
196 let idx = FileIndex::from_entries(vec![
197 FileEntry {
198 path: std::path::Path::new("pkg/marker.lock").into(),
199 is_dir: false,
200 size: 1,
201 },
202 FileEntry {
203 path: std::path::Path::new("pkg/small.txt").into(),
204 is_dir: false,
205 size: 5,
206 },
207 FileEntry {
208 path: std::path::Path::new("other/small.txt").into(),
209 is_dir: false,
210 size: 5,
211 },
212 ]);
213 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
214 assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
215 assert_eq!(v[0].path.as_deref(), Some(Path::new("pkg/small.txt")));
216 }
217}