1use alint_core::template::PathTokens;
33use alint_core::when::{IterEnv, WhenExpr};
34use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
35use serde::Deserialize;
36
37#[derive(Debug, Deserialize)]
38#[serde(deny_unknown_fields)]
39struct Options {
40 select: String,
41 #[serde(default)]
46 when_iter: Option<String>,
47 require: Vec<NestedRuleSpec>,
48}
49
50#[derive(Debug)]
51pub struct ForEachDirRule {
52 id: String,
53 level: Level,
54 policy_url: Option<String>,
55 select_scope: Scope,
56 when_iter: Option<WhenExpr>,
57 require: Vec<NestedRuleSpec>,
58}
59
60impl Rule for ForEachDirRule {
61 fn id(&self) -> &str {
62 &self.id
63 }
64 fn level(&self) -> Level {
65 self.level
66 }
67 fn policy_url(&self) -> Option<&str> {
68 self.policy_url.as_deref()
69 }
70
71 fn requires_full_index(&self) -> bool {
72 true
78 }
79
80 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
81 evaluate_for_each(
82 &self.id,
83 self.level,
84 &self.select_scope,
85 self.when_iter.as_ref(),
86 &self.require,
87 ctx,
88 IterateMode::Dirs,
89 )
90 }
91}
92
93pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
94 alint_core::reject_scope_filter_on_cross_file(spec, "for_each_dir")?;
95 let opts: Options = spec
96 .deserialize_options()
97 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
98 if opts.require.is_empty() {
99 return Err(Error::rule_config(
100 &spec.id,
101 "for_each_dir requires at least one nested rule under `require:`",
102 ));
103 }
104 let select_scope = Scope::from_patterns(&[opts.select])?;
105 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
106 Ok(Box::new(ForEachDirRule {
107 id: spec.id.clone(),
108 level: spec.level,
109 policy_url: spec.policy_url.clone(),
110 select_scope,
111 when_iter,
112 require: opts.require,
113 }))
114}
115
116pub(crate) fn parse_when_iter(spec: &RuleSpec, src: Option<&str>) -> Result<Option<WhenExpr>> {
121 let Some(src) = src else { return Ok(None) };
122 alint_core::when::parse(src)
123 .map(Some)
124 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `when_iter:`: {e}")))
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub(crate) enum IterateMode {
130 Dirs,
131 Files,
132 Both,
134}
135
136pub(crate) fn evaluate_for_each(
142 parent_id: &str,
143 level: Level,
144 select_scope: &Scope,
145 when_iter: Option<&WhenExpr>,
146 require: &[NestedRuleSpec],
147 ctx: &Context<'_>,
148 mode: IterateMode,
149) -> Result<Vec<Violation>> {
150 let Some(registry) = ctx.registry else {
151 return Err(Error::Other(format!(
152 "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
153 (likely an Engine constructed without one)",
154 )));
155 };
156
157 let entries: Box<dyn Iterator<Item = _>> = match mode {
158 IterateMode::Dirs => Box::new(ctx.index.dirs()),
159 IterateMode::Files => Box::new(ctx.index.files()),
160 IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
161 };
162
163 let mut violations = Vec::new();
164 for entry in entries {
165 if !select_scope.matches(&entry.path) {
166 continue;
167 }
168
169 let iter_env = IterEnv {
174 path: &entry.path,
175 is_dir: entry.is_dir,
176 index: ctx.index,
177 };
178 if let Some(expr) = when_iter {
179 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
180 let env = alint_core::WhenEnv {
181 facts,
182 vars,
183 iter: Some(iter_env),
184 };
185 match expr.evaluate(&env) {
186 Ok(true) => {}
187 Ok(false) => continue,
188 Err(e) => {
189 violations.push(
190 Violation::new(format!("{parent_id}: when_iter error: {e}"))
191 .with_path(entry.path.clone()),
192 );
193 continue;
194 }
195 }
196 }
197 }
198
199 let tokens = PathTokens::from_path(&entry.path);
200 for (i, nested) in require.iter().enumerate() {
201 let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
202 if let Some(when_src) = &nested_spec.when {
207 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
208 let expr = alint_core::when::parse(when_src).map_err(|e| {
209 Error::rule_config(
210 parent_id,
211 format!("nested rule #{i}: invalid when: {e}"),
212 )
213 })?;
214 let env = alint_core::WhenEnv {
215 facts,
216 vars,
217 iter: Some(iter_env),
218 };
219 match expr.evaluate(&env) {
220 Ok(true) => {}
221 Ok(false) => continue,
222 Err(e) => {
223 violations.push(
224 Violation::new(format!(
225 "{parent_id}: nested rule #{i} when error: {e}"
226 ))
227 .with_path(entry.path.clone()),
228 );
229 continue;
230 }
231 }
232 }
233 }
234 let nested_rule = match registry.build(&nested_spec) {
235 Ok(r) => r,
236 Err(e) => {
237 violations.push(
238 Violation::new(format!(
239 "{parent_id}: failed to build nested rule #{i} for {}: {e}",
240 entry.path.display()
241 ))
242 .with_path(entry.path.clone()),
243 );
244 continue;
245 }
246 };
247 let nested_violations = nested_rule.evaluate(ctx)?;
248 for mut v in nested_violations {
249 if v.path.is_none() {
250 v.path = Some(entry.path.clone());
251 }
252 violations.push(v);
253 }
254 }
255 }
256 Ok(violations)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use alint_core::{FileEntry, FileIndex, RuleRegistry};
263 use std::path::Path;
264
265 fn index(entries: &[(&str, bool)]) -> FileIndex {
266 FileIndex::from_entries(
267 entries
268 .iter()
269 .map(|(p, is_dir)| FileEntry {
270 path: std::path::Path::new(p).into(),
271 is_dir: *is_dir,
272 size: 1,
273 })
274 .collect(),
275 )
276 }
277
278 fn registry() -> RuleRegistry {
279 crate::builtin_registry()
280 }
281
282 fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
283 let idx = index(files);
284 let reg = registry();
285 let ctx = Context {
286 root: Path::new("/"),
287 index: &idx,
288 registry: Some(®),
289 facts: None,
290 vars: None,
291 git_tracked: None,
292 git_blame: None,
293 };
294 rule.evaluate(&ctx).unwrap()
295 }
296
297 fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
298 ForEachDirRule {
299 id: "t".into(),
300 level: Level::Error,
301 policy_url: None,
302 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
303 when_iter: None,
304 require,
305 }
306 }
307
308 fn require_file_exists(path: &str) -> NestedRuleSpec {
309 let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
311 serde_yaml_ng::from_str(&yaml).unwrap()
312 }
313
314 #[test]
315 fn passes_when_every_dir_has_required_file() {
316 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
317 let v = eval_with(
318 &r,
319 &[
320 ("src", true),
321 ("src/foo", true),
322 ("src/foo/mod.rs", false),
323 ("src/bar", true),
324 ("src/bar/mod.rs", false),
325 ],
326 );
327 assert!(v.is_empty(), "unexpected: {v:?}");
328 }
329
330 #[test]
331 fn violates_when_a_dir_missing_required_file() {
332 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
333 let v = eval_with(
334 &r,
335 &[
336 ("src", true),
337 ("src/foo", true),
338 ("src/foo/mod.rs", false),
339 ("src/bar", true), ],
341 );
342 assert_eq!(v.len(), 1);
343 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
344 }
345
346 #[test]
347 fn no_matched_dirs_means_no_violations() {
348 let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
349 let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
350 assert!(v.is_empty());
351 }
352
353 #[test]
354 fn every_require_rule_evaluated_per_dir() {
355 let r = rule(
356 "src/*",
357 vec![
358 require_file_exists("{path}/mod.rs"),
359 require_file_exists("{path}/README.md"),
360 ],
361 );
362 let v = eval_with(
363 &r,
364 &[
365 ("src", true),
366 ("src/foo", true),
367 ("src/foo/mod.rs", false), ],
369 );
370 assert_eq!(v.len(), 1);
371 assert!(
372 v[0].message.contains("README"),
373 "expected README in message; got {:?}",
374 v[0].message
375 );
376 }
377
378 #[test]
379 fn build_rejects_scope_filter_on_cross_file_rule() {
380 let yaml = r#"
385id: t
386kind: for_each_dir
387select: "src/*"
388require:
389 - kind: file_exists
390 paths: "{path}/mod.rs"
391level: error
392scope_filter:
393 has_ancestor: Cargo.toml
394"#;
395 let spec = crate::test_support::spec_yaml(yaml);
396 let err = build(&spec).unwrap_err().to_string();
397 assert!(
398 err.contains("scope_filter is supported on per-file rules only"),
399 "expected per-file-only message, got: {err}",
400 );
401 assert!(
402 err.contains("for_each_dir"),
403 "expected message to name the cross-file kind, got: {err}",
404 );
405 }
406}