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