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 alint_core::rule_common_impl!();
59
60 fn requires_full_index(&self) -> bool {
61 true
65 }
66
67 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
68 let mut violations = Vec::new();
69 for dir in ctx.index.dirs() {
70 if !self.select_scope.matches(&dir.path, ctx.index) {
71 continue;
72 }
73 for &child_idx in ctx.index.children_of(&dir.path) {
78 let file = &ctx.index.entries[child_idx];
79 if file.is_dir {
80 continue;
81 }
82 let Some(basename) = file.path.file_name().and_then(|s| s.to_str()) else {
83 continue;
84 };
85 if self.allow_matcher.is_match(basename) {
86 continue;
87 }
88 let msg = self.format_message(&dir.path, &file.path, basename);
89 violations.push(Violation::new(msg).with_path(file.path.clone()));
90 }
91 }
92 Ok(violations)
93 }
94}
95
96impl DirOnlyContainsRule {
97 fn format_message(&self, dir: &Path, file: &Path, basename: &str) -> String {
98 if let Some(user) = self.message.as_deref() {
99 let dir_str = dir.display().to_string();
100 let file_str = file.display().to_string();
101 let basename_str = basename.to_string();
102 return alint_core::template::render_message(user, |ns, key| match (ns, key) {
103 ("ctx", "dir") => Some(dir_str.clone()),
104 ("ctx", "file") => Some(file_str.clone()),
105 ("ctx", "basename") => Some(basename_str.clone()),
106 _ => None,
107 });
108 }
109 format!(
110 "{} is not allowed in {} (allow: [{}])",
111 file.display(),
112 dir.display(),
113 self.allow_globs.join(", "),
114 )
115 }
116}
117
118pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
119 alint_core::reject_scope_filter_on_cross_file(spec, "dir_only_contains")?;
120 let opts: Options = spec
121 .deserialize_options()
122 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
123 let allow_globs = opts.allow.into_vec();
124 if allow_globs.is_empty() {
125 return Err(Error::rule_config(
126 &spec.id,
127 "dir_only_contains `allow` must not be empty",
128 ));
129 }
130 let select_scope = Scope::from_patterns(&[opts.select])?;
131 let mut builder = GlobSetBuilder::new();
132 for pat in &allow_globs {
133 let glob = Glob::new(pat).map_err(|source| Error::Glob {
134 pattern: pat.clone(),
135 source,
136 })?;
137 builder.add(glob);
138 }
139 let allow_matcher = builder.build().map_err(|source| Error::Glob {
140 pattern: allow_globs.join(","),
141 source,
142 })?;
143 Ok(Box::new(DirOnlyContainsRule {
144 id: spec.id.clone(),
145 level: spec.level,
146 policy_url: spec.policy_url.clone(),
147 message: spec.message.clone(),
148 select_scope,
149 allow_globs,
150 allow_matcher,
151 }))
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use alint_core::{FileEntry, FileIndex};
158
159 fn index(entries: &[(&str, bool)]) -> FileIndex {
160 FileIndex::from_entries(
161 entries
162 .iter()
163 .map(|(p, is_dir)| FileEntry {
164 path: std::path::Path::new(p).into(),
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 git_tracked: None,
198 git_blame: None,
199 };
200 rule.evaluate(&ctx).unwrap()
201 }
202
203 #[test]
204 fn passes_when_every_child_allowed() {
205 let r = rule("src/*", &["*.rs", "mod.rs"]);
206 let v = eval(
207 &r,
208 &[
209 ("src", true),
210 ("src/foo", true),
211 ("src/foo/lib.rs", false),
212 ("src/foo/mod.rs", false),
213 ("src/bar", true),
214 ("src/bar/main.rs", false),
215 ],
216 );
217 assert!(v.is_empty(), "unexpected: {v:?}");
218 }
219
220 #[test]
221 fn flags_disallowed_child() {
222 let r = rule("src/*", &["*.rs"]);
223 let v = eval(
224 &r,
225 &[
226 ("src", true),
227 ("src/foo", true),
228 ("src/foo/lib.rs", false),
229 ("src/foo/README.md", false), ],
231 );
232 assert_eq!(v.len(), 1);
233 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/foo/README.md")));
234 }
235
236 #[test]
237 fn multiple_disallowed_children_emit_multiple_violations() {
238 let r = rule("src/*", &["*.rs"]);
239 let v = eval(
240 &r,
241 &[
242 ("src", true),
243 ("src/foo", true),
244 ("src/foo/a.rs", false),
245 ("src/foo/a.md", false), ("src/foo/a.json", false), ],
248 );
249 assert_eq!(v.len(), 2);
250 }
251
252 #[test]
253 fn subdirectories_are_not_flagged() {
254 let r = rule("src/*", &["*.rs"]);
257 let v = eval(
258 &r,
259 &[
260 ("src", true),
261 ("src/foo", true),
262 ("src/foo/a.rs", false),
263 ("src/foo/inner", true), ],
265 );
266 assert!(v.is_empty());
267 }
268
269 #[test]
270 fn deeper_files_are_not_direct_children() {
271 let r = rule("src/*", &["*.rs"]);
274 let v = eval(
275 &r,
276 &[
277 ("src", true),
278 ("src/foo", true),
279 ("src/foo/a.rs", false),
280 ("src/foo/inner", true),
281 ("src/foo/inner/weird.bin", false), ],
283 );
284 assert!(v.is_empty());
285 }
286
287 #[test]
288 fn no_matched_dirs_means_no_violations() {
289 let r = rule("components/*", &["*.tsx"]);
290 let v = eval(&r, &[("src", true), ("src/foo", true)]);
291 assert!(v.is_empty());
292 }
293
294 #[test]
295 fn allow_can_be_single_string() {
296 let yaml = r"
297select: src/*
298allow: '*.rs'
299";
300 let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
301 assert!(matches!(opts.allow, super::AllowList::One(_)));
302 }
303
304 #[test]
305 fn allow_can_be_list() {
306 let yaml = r#"
307select: src/*
308allow: ["*.rs", "*.toml"]
309"#;
310 let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
311 assert!(matches!(opts.allow, super::AllowList::Many(_)));
312 }
313
314 #[test]
315 fn build_rejects_scope_filter_on_cross_file_rule() {
316 let yaml = r#"
322id: t
323kind: dir_only_contains
324select: "src/*"
325allow: ["*.rs"]
326level: error
327scope_filter:
328 has_ancestor: Cargo.toml
329"#;
330 let spec = crate::test_support::spec_yaml(yaml);
331 let err = build(&spec).unwrap_err().to_string();
332 assert!(
333 err.contains("scope_filter is supported on per-file rules only"),
334 "expected per-file-only message, got: {err}",
335 );
336 assert!(
337 err.contains("dir_only_contains"),
338 "expected message to name the cross-file kind, got: {err}",
339 );
340 }
341}