1use alint_core::when::WhenExpr;
20use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
21use serde::Deserialize;
22
23use crate::for_each_dir::{IterateMode, evaluate_for_each, parse_when_iter};
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Options {
28 select: String,
29 #[serde(default)]
34 when_iter: Option<String>,
35 require: Vec<NestedRuleSpec>,
36}
37
38#[derive(Debug)]
39pub struct ForEachFileRule {
40 id: String,
41 level: Level,
42 policy_url: Option<String>,
43 select_scope: Scope,
44 when_iter: Option<WhenExpr>,
45 require: Vec<NestedRuleSpec>,
46}
47
48impl Rule for ForEachFileRule {
49 fn id(&self) -> &str {
50 &self.id
51 }
52 fn level(&self) -> Level {
53 self.level
54 }
55 fn policy_url(&self) -> Option<&str> {
56 self.policy_url.as_deref()
57 }
58
59 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
60 evaluate_for_each(
61 &self.id,
62 self.level,
63 &self.select_scope,
64 self.when_iter.as_ref(),
65 &self.require,
66 ctx,
67 IterateMode::Files,
68 )
69 }
70}
71
72pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
73 let opts: Options = spec
74 .deserialize_options()
75 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
76 if opts.require.is_empty() {
77 return Err(Error::rule_config(
78 &spec.id,
79 "for_each_file requires at least one nested rule under `require:`",
80 ));
81 }
82 let select_scope = Scope::from_patterns(&[opts.select])?;
83 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
84 Ok(Box::new(ForEachFileRule {
85 id: spec.id.clone(),
86 level: spec.level,
87 policy_url: spec.policy_url.clone(),
88 select_scope,
89 when_iter,
90 require: opts.require,
91 }))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use alint_core::{FileEntry, FileIndex, RuleRegistry};
98 use std::path::{Path, PathBuf};
99
100 fn index(entries: &[(&str, bool)]) -> FileIndex {
101 FileIndex {
102 entries: entries
103 .iter()
104 .map(|(p, is_dir)| FileEntry {
105 path: PathBuf::from(p),
106 is_dir: *is_dir,
107 size: 1,
108 })
109 .collect(),
110 }
111 }
112
113 fn registry() -> RuleRegistry {
114 crate::builtin_registry()
115 }
116
117 #[test]
118 fn passes_when_every_file_has_required_sibling() {
119 let require: Vec<NestedRuleSpec> = vec![
120 serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
121 ];
122 let r = ForEachFileRule {
123 id: "t".into(),
124 level: Level::Error,
125 policy_url: None,
126 select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
127 when_iter: None,
128 require,
129 };
130 let idx = index(&[
131 ("src/foo.c", false),
132 ("src/foo.h", false),
133 ("src/bar.c", false),
134 ("src/bar.h", false),
135 ]);
136 let reg = registry();
137 let ctx = Context {
138 root: Path::new("/"),
139 index: &idx,
140 registry: Some(®),
141 facts: None,
142 vars: None,
143 git_tracked: None,
144 };
145 let v = r.evaluate(&ctx).unwrap();
146 assert!(v.is_empty(), "unexpected: {v:?}");
147 }
148
149 #[test]
150 fn violates_per_missing_sibling() {
151 let require: Vec<NestedRuleSpec> = vec![
152 serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
153 ];
154 let r = ForEachFileRule {
155 id: "t".into(),
156 level: Level::Error,
157 policy_url: None,
158 select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
159 when_iter: None,
160 require,
161 };
162 let idx = index(&[
163 ("src/foo.c", false),
164 ("src/foo.h", false), ("src/bar.c", false), ("src/baz.c", false), ]);
168 let reg = registry();
169 let ctx = Context {
170 root: Path::new("/"),
171 index: &idx,
172 registry: Some(®),
173 facts: None,
174 vars: None,
175 git_tracked: None,
176 };
177 let v = r.evaluate(&ctx).unwrap();
178 assert_eq!(v.len(), 2);
179 }
180}