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