1use alint_core::when::WhenExpr;
20use alint_core::{
21 CompiledNestedSpec, Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope,
22 Violation,
23};
24use serde::Deserialize;
25
26use crate::for_each_dir::{
27 IterateMode, compile_nested_require, evaluate_for_each, parse_when_iter,
28};
29
30#[derive(Debug, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Options {
33 select: String,
34 #[serde(default)]
39 when_iter: Option<String>,
40 require: Vec<NestedRuleSpec>,
41}
42
43#[derive(Debug)]
44pub struct ForEachFileRule {
45 id: String,
46 level: Level,
47 policy_url: Option<String>,
48 select_scope: Scope,
49 when_iter: Option<WhenExpr>,
50 require: Vec<CompiledNestedSpec>,
51}
52
53impl Rule for ForEachFileRule {
54 fn id(&self) -> &str {
55 &self.id
56 }
57 fn level(&self) -> Level {
58 self.level
59 }
60 fn policy_url(&self) -> Option<&str> {
61 self.policy_url.as_deref()
62 }
63
64 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
65 evaluate_for_each(
66 &self.id,
67 self.level,
68 &self.select_scope,
69 self.when_iter.as_ref(),
70 &self.require,
71 ctx,
72 IterateMode::Files,
73 )
74 }
75}
76
77pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
78 alint_core::reject_scope_filter_on_cross_file(spec, "for_each_file")?;
79 let opts: Options = spec
80 .deserialize_options()
81 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
82 if opts.require.is_empty() {
83 return Err(Error::rule_config(
84 &spec.id,
85 "for_each_file requires at least one nested rule under `require:`",
86 ));
87 }
88 let select_scope = Scope::from_patterns(&[opts.select])?;
89 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
90 let require = compile_nested_require(&spec.id, opts.require)?;
91 Ok(Box::new(ForEachFileRule {
92 id: spec.id.clone(),
93 level: spec.level,
94 policy_url: spec.policy_url.clone(),
95 select_scope,
96 when_iter,
97 require,
98 }))
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use alint_core::{FileEntry, FileIndex, RuleRegistry};
105 use std::path::Path;
106
107 fn index(entries: &[(&str, bool)]) -> FileIndex {
108 FileIndex::from_entries(
109 entries
110 .iter()
111 .map(|(p, is_dir)| FileEntry {
112 path: std::path::Path::new(p).into(),
113 is_dir: *is_dir,
114 size: 1,
115 })
116 .collect(),
117 )
118 }
119
120 fn registry() -> RuleRegistry {
121 crate::builtin_registry()
122 }
123
124 #[test]
125 fn passes_when_every_file_has_required_sibling() {
126 let require: Vec<NestedRuleSpec> = vec![
127 serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
128 ];
129 let require = compile_nested_require("t", require).unwrap();
130 let r = ForEachFileRule {
131 id: "t".into(),
132 level: Level::Error,
133 policy_url: None,
134 select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
135 when_iter: None,
136 require,
137 };
138 let idx = index(&[
139 ("src/foo.c", false),
140 ("src/foo.h", false),
141 ("src/bar.c", false),
142 ("src/bar.h", false),
143 ]);
144 let reg = registry();
145 let ctx = Context {
146 root: Path::new("/"),
147 index: &idx,
148 registry: Some(®),
149 facts: None,
150 vars: None,
151 git_tracked: None,
152 git_blame: None,
153 };
154 let v = r.evaluate(&ctx).unwrap();
155 assert!(v.is_empty(), "unexpected: {v:?}");
156 }
157
158 #[test]
159 fn violates_per_missing_sibling() {
160 let require: Vec<NestedRuleSpec> = vec![
161 serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
162 ];
163 let require = compile_nested_require("t", require).unwrap();
164 let r = ForEachFileRule {
165 id: "t".into(),
166 level: Level::Error,
167 policy_url: None,
168 select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
169 when_iter: None,
170 require,
171 };
172 let idx = index(&[
173 ("src/foo.c", false),
174 ("src/foo.h", false), ("src/bar.c", false), ("src/baz.c", false), ]);
178 let reg = registry();
179 let ctx = Context {
180 root: Path::new("/"),
181 index: &idx,
182 registry: Some(®),
183 facts: None,
184 vars: None,
185 git_tracked: None,
186 git_blame: None,
187 };
188 let v = r.evaluate(&ctx).unwrap();
189 assert_eq!(v.len(), 2);
190 }
191
192 #[test]
193 fn build_rejects_scope_filter_on_cross_file_rule() {
194 let yaml = r#"
199id: t
200kind: for_each_file
201select: "**/*.c"
202require:
203 - kind: file_exists
204 paths: "{dir}/{stem}.h"
205level: error
206scope_filter:
207 has_ancestor: Cargo.toml
208"#;
209 let spec = crate::test_support::spec_yaml(yaml);
210 let err = build(&spec).unwrap_err().to_string();
211 assert!(
212 err.contains("scope_filter is supported on per-file rules only"),
213 "expected per-file-only message, got: {err}",
214 );
215 assert!(
216 err.contains("for_each_file"),
217 "expected message to name the cross-file kind, got: {err}",
218 );
219 }
220}