1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
19use globset::{Glob, GlobSet, GlobSetBuilder};
20use serde::Deserialize;
21use std::path::Path;
22
23#[derive(Debug, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct Options {
26 select: String,
27 allow: AllowList,
28}
29
30#[derive(Debug, Deserialize)]
31#[serde(untagged)]
32enum AllowList {
33 One(String),
34 Many(Vec<String>),
35}
36
37impl AllowList {
38 fn into_vec(self) -> Vec<String> {
39 match self {
40 Self::One(s) => vec![s],
41 Self::Many(v) => v,
42 }
43 }
44}
45
46#[derive(Debug)]
47pub struct DirOnlyContainsRule {
48 id: String,
49 level: Level,
50 policy_url: Option<String>,
51 message: Option<String>,
52 select_scope: Scope,
53 allow_globs: Vec<String>,
54 allow_matcher: GlobSet,
55}
56
57impl Rule for DirOnlyContainsRule {
58 fn id(&self) -> &str {
59 &self.id
60 }
61 fn level(&self) -> Level {
62 self.level
63 }
64 fn policy_url(&self) -> Option<&str> {
65 self.policy_url.as_deref()
66 }
67
68 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
69 let mut violations = Vec::new();
70 for dir in ctx.index.dirs() {
71 if !self.select_scope.matches(&dir.path) {
72 continue;
73 }
74 for file in ctx.index.files() {
75 if !is_direct_child(&file.path, &dir.path) {
76 continue;
77 }
78 let Some(basename) = file.path.file_name().and_then(|s| s.to_str()) else {
79 continue;
80 };
81 if self.allow_matcher.is_match(basename) {
82 continue;
83 }
84 let msg = self.format_message(&dir.path, &file.path, basename);
85 violations.push(Violation::new(msg).with_path(&file.path));
86 }
87 }
88 Ok(violations)
89 }
90}
91
92impl DirOnlyContainsRule {
93 fn format_message(&self, dir: &Path, file: &Path, basename: &str) -> String {
94 if let Some(user) = self.message.as_deref() {
95 let dir_str = dir.display().to_string();
96 let file_str = file.display().to_string();
97 let basename_str = basename.to_string();
98 return alint_core::template::render_message(user, |ns, key| match (ns, key) {
99 ("ctx", "dir") => Some(dir_str.clone()),
100 ("ctx", "file") => Some(file_str.clone()),
101 ("ctx", "basename") => Some(basename_str.clone()),
102 _ => None,
103 });
104 }
105 format!(
106 "{} is not allowed in {} (allow: [{}])",
107 file.display(),
108 dir.display(),
109 self.allow_globs.join(", "),
110 )
111 }
112}
113
114fn is_direct_child(child: &Path, parent: &Path) -> bool {
115 child.parent() == Some(parent)
116}
117
118pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
119 let opts: Options = spec
120 .deserialize_options()
121 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
122 let allow_globs = opts.allow.into_vec();
123 if allow_globs.is_empty() {
124 return Err(Error::rule_config(
125 &spec.id,
126 "dir_only_contains `allow` must not be empty",
127 ));
128 }
129 let select_scope = Scope::from_patterns(&[opts.select])?;
130 let mut builder = GlobSetBuilder::new();
131 for pat in &allow_globs {
132 let glob = Glob::new(pat).map_err(|source| Error::Glob {
133 pattern: pat.clone(),
134 source,
135 })?;
136 builder.add(glob);
137 }
138 let allow_matcher = builder.build().map_err(|source| Error::Glob {
139 pattern: allow_globs.join(","),
140 source,
141 })?;
142 Ok(Box::new(DirOnlyContainsRule {
143 id: spec.id.clone(),
144 level: spec.level,
145 policy_url: spec.policy_url.clone(),
146 message: spec.message.clone(),
147 select_scope,
148 allow_globs,
149 allow_matcher,
150 }))
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use alint_core::{FileEntry, FileIndex};
157 use std::path::PathBuf;
158
159 fn index(entries: &[(&str, bool)]) -> FileIndex {
160 FileIndex {
161 entries: entries
162 .iter()
163 .map(|(p, is_dir)| FileEntry {
164 path: PathBuf::from(p),
165 is_dir: *is_dir,
166 size: 1,
167 })
168 .collect(),
169 }
170 }
171
172 fn rule(select: &str, allow: &[&str]) -> DirOnlyContainsRule {
173 let allow_globs: Vec<String> = allow.iter().map(|s| (*s).to_string()).collect();
174 let mut builder = GlobSetBuilder::new();
175 for p in &allow_globs {
176 builder.add(Glob::new(p).unwrap());
177 }
178 DirOnlyContainsRule {
179 id: "t".into(),
180 level: Level::Error,
181 policy_url: None,
182 message: None,
183 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
184 allow_globs,
185 allow_matcher: builder.build().unwrap(),
186 }
187 }
188
189 fn eval(rule: &DirOnlyContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
190 let idx = index(files);
191 let ctx = Context {
192 root: Path::new("/"),
193 index: &idx,
194 registry: None,
195 facts: None,
196 vars: None,
197 };
198 rule.evaluate(&ctx).unwrap()
199 }
200
201 #[test]
202 fn passes_when_every_child_allowed() {
203 let r = rule("src/*", &["*.rs", "mod.rs"]);
204 let v = eval(
205 &r,
206 &[
207 ("src", true),
208 ("src/foo", true),
209 ("src/foo/lib.rs", false),
210 ("src/foo/mod.rs", false),
211 ("src/bar", true),
212 ("src/bar/main.rs", false),
213 ],
214 );
215 assert!(v.is_empty(), "unexpected: {v:?}");
216 }
217
218 #[test]
219 fn flags_disallowed_child() {
220 let r = rule("src/*", &["*.rs"]);
221 let v = eval(
222 &r,
223 &[
224 ("src", true),
225 ("src/foo", true),
226 ("src/foo/lib.rs", false),
227 ("src/foo/README.md", false), ],
229 );
230 assert_eq!(v.len(), 1);
231 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/foo/README.md")));
232 }
233
234 #[test]
235 fn multiple_disallowed_children_emit_multiple_violations() {
236 let r = rule("src/*", &["*.rs"]);
237 let v = eval(
238 &r,
239 &[
240 ("src", true),
241 ("src/foo", true),
242 ("src/foo/a.rs", false),
243 ("src/foo/a.md", false), ("src/foo/a.json", false), ],
246 );
247 assert_eq!(v.len(), 2);
248 }
249
250 #[test]
251 fn subdirectories_are_not_flagged() {
252 let r = rule("src/*", &["*.rs"]);
255 let v = eval(
256 &r,
257 &[
258 ("src", true),
259 ("src/foo", true),
260 ("src/foo/a.rs", false),
261 ("src/foo/inner", true), ],
263 );
264 assert!(v.is_empty());
265 }
266
267 #[test]
268 fn deeper_files_are_not_direct_children() {
269 let r = rule("src/*", &["*.rs"]);
272 let v = eval(
273 &r,
274 &[
275 ("src", true),
276 ("src/foo", true),
277 ("src/foo/a.rs", false),
278 ("src/foo/inner", true),
279 ("src/foo/inner/weird.bin", false), ],
281 );
282 assert!(v.is_empty());
283 }
284
285 #[test]
286 fn no_matched_dirs_means_no_violations() {
287 let r = rule("components/*", &["*.tsx"]);
288 let v = eval(&r, &[("src", true), ("src/foo", true)]);
289 assert!(v.is_empty());
290 }
291
292 #[test]
293 fn allow_can_be_single_string() {
294 let yaml = r"
295select: src/*
296allow: '*.rs'
297";
298 let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
299 assert!(matches!(opts.allow, super::AllowList::One(_)));
300 }
301
302 #[test]
303 fn allow_can_be_list() {
304 let yaml = r#"
305select: src/*
306allow: ["*.rs", "*.toml"]
307"#;
308 let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
309 assert!(matches!(opts.allow, super::AllowList::Many(_)));
310 }
311}