1use alint_core::template::PathTokens;
33use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
34use serde::Deserialize;
35
36#[derive(Debug, Deserialize)]
37#[serde(deny_unknown_fields)]
38struct Options {
39 select: String,
40 require: Vec<NestedRuleSpec>,
41}
42
43#[derive(Debug)]
44pub struct ForEachDirRule {
45 id: String,
46 level: Level,
47 policy_url: Option<String>,
48 select_scope: Scope,
49 require: Vec<NestedRuleSpec>,
50}
51
52impl Rule for ForEachDirRule {
53 fn id(&self) -> &str {
54 &self.id
55 }
56 fn level(&self) -> Level {
57 self.level
58 }
59 fn policy_url(&self) -> Option<&str> {
60 self.policy_url.as_deref()
61 }
62
63 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
64 evaluate_for_each(
65 &self.id,
66 self.level,
67 &self.select_scope,
68 &self.require,
69 ctx,
70 IterateMode::Dirs,
71 )
72 }
73}
74
75pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
76 let opts: Options = spec
77 .deserialize_options()
78 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
79 if opts.require.is_empty() {
80 return Err(Error::rule_config(
81 &spec.id,
82 "for_each_dir requires at least one nested rule under `require:`",
83 ));
84 }
85 let select_scope = Scope::from_patterns(&[opts.select])?;
86 Ok(Box::new(ForEachDirRule {
87 id: spec.id.clone(),
88 level: spec.level,
89 policy_url: spec.policy_url.clone(),
90 select_scope,
91 require: opts.require,
92 }))
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub(crate) enum IterateMode {
98 Dirs,
99 Files,
100 Both,
102}
103
104pub(crate) fn evaluate_for_each(
107 parent_id: &str,
108 level: Level,
109 select_scope: &Scope,
110 require: &[NestedRuleSpec],
111 ctx: &Context<'_>,
112 mode: IterateMode,
113) -> Result<Vec<Violation>> {
114 let Some(registry) = ctx.registry else {
115 return Err(Error::Other(format!(
116 "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
117 (likely an Engine constructed without one)",
118 )));
119 };
120
121 let entries: Box<dyn Iterator<Item = _>> = match mode {
122 IterateMode::Dirs => Box::new(ctx.index.dirs()),
123 IterateMode::Files => Box::new(ctx.index.files()),
124 IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
125 };
126
127 let mut violations = Vec::new();
128 for entry in entries {
129 if !select_scope.matches(&entry.path) {
130 continue;
131 }
132 let tokens = PathTokens::from_path(&entry.path);
133 for (i, nested) in require.iter().enumerate() {
134 let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
135 if let Some(when_src) = &nested_spec.when {
137 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
138 let expr = alint_core::when::parse(when_src).map_err(|e| {
139 Error::rule_config(
140 parent_id,
141 format!("nested rule #{i}: invalid when: {e}"),
142 )
143 })?;
144 let env = alint_core::WhenEnv { facts, vars };
145 match expr.evaluate(&env) {
146 Ok(true) => {}
147 Ok(false) => continue,
148 Err(e) => {
149 violations.push(
150 Violation::new(format!(
151 "{parent_id}: nested rule #{i} when error: {e}"
152 ))
153 .with_path(&entry.path),
154 );
155 continue;
156 }
157 }
158 }
159 }
160 let nested_rule = match registry.build(&nested_spec) {
161 Ok(r) => r,
162 Err(e) => {
163 violations.push(
164 Violation::new(format!(
165 "{parent_id}: failed to build nested rule #{i} for {}: {e}",
166 entry.path.display()
167 ))
168 .with_path(&entry.path),
169 );
170 continue;
171 }
172 };
173 let nested_violations = nested_rule.evaluate(ctx)?;
174 for mut v in nested_violations {
175 if v.path.is_none() {
176 v.path = Some(entry.path.clone());
177 }
178 violations.push(v);
179 }
180 }
181 }
182 Ok(violations)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use alint_core::{FileEntry, FileIndex, RuleRegistry};
189 use std::path::{Path, PathBuf};
190
191 fn index(entries: &[(&str, bool)]) -> FileIndex {
192 FileIndex {
193 entries: entries
194 .iter()
195 .map(|(p, is_dir)| FileEntry {
196 path: PathBuf::from(p),
197 is_dir: *is_dir,
198 size: 1,
199 })
200 .collect(),
201 }
202 }
203
204 fn registry() -> RuleRegistry {
205 crate::builtin_registry()
206 }
207
208 fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
209 let idx = index(files);
210 let reg = registry();
211 let ctx = Context {
212 root: Path::new("/"),
213 index: &idx,
214 registry: Some(®),
215 facts: None,
216 vars: None,
217 };
218 rule.evaluate(&ctx).unwrap()
219 }
220
221 fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
222 ForEachDirRule {
223 id: "t".into(),
224 level: Level::Error,
225 policy_url: None,
226 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
227 require,
228 }
229 }
230
231 fn require_file_exists(path: &str) -> NestedRuleSpec {
232 let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
234 serde_yaml_ng::from_str(&yaml).unwrap()
235 }
236
237 #[test]
238 fn passes_when_every_dir_has_required_file() {
239 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
240 let v = eval_with(
241 &r,
242 &[
243 ("src", true),
244 ("src/foo", true),
245 ("src/foo/mod.rs", false),
246 ("src/bar", true),
247 ("src/bar/mod.rs", false),
248 ],
249 );
250 assert!(v.is_empty(), "unexpected: {v:?}");
251 }
252
253 #[test]
254 fn violates_when_a_dir_missing_required_file() {
255 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
256 let v = eval_with(
257 &r,
258 &[
259 ("src", true),
260 ("src/foo", true),
261 ("src/foo/mod.rs", false),
262 ("src/bar", true), ],
264 );
265 assert_eq!(v.len(), 1);
266 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
267 }
268
269 #[test]
270 fn no_matched_dirs_means_no_violations() {
271 let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
272 let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
273 assert!(v.is_empty());
274 }
275
276 #[test]
277 fn every_require_rule_evaluated_per_dir() {
278 let r = rule(
279 "src/*",
280 vec![
281 require_file_exists("{path}/mod.rs"),
282 require_file_exists("{path}/README.md"),
283 ],
284 );
285 let v = eval_with(
286 &r,
287 &[
288 ("src", true),
289 ("src/foo", true),
290 ("src/foo/mod.rs", false), ],
292 );
293 assert_eq!(v.len(), 1);
294 assert!(
295 v[0].message.contains("README"),
296 "expected README in message; got {:?}",
297 v[0].message
298 );
299 }
300}