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