1use std::collections::BTreeMap;
22use std::path::PathBuf;
23
24use alint_core::template::{PathTokens, render_message, render_path};
25use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
26use serde::Deserialize;
27
28#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30struct Options {
31 select: String,
32 #[serde(default = "default_key")]
33 key: String,
34}
35
36fn default_key() -> String {
37 "{basename}".to_string()
38}
39
40#[derive(Debug)]
41pub struct UniqueByRule {
42 id: String,
43 level: Level,
44 policy_url: Option<String>,
45 message: Option<String>,
46 select_scope: Scope,
47 key_template: String,
48}
49
50impl Rule for UniqueByRule {
51 fn id(&self) -> &str {
52 &self.id
53 }
54 fn level(&self) -> Level {
55 self.level
56 }
57 fn policy_url(&self) -> Option<&str> {
58 self.policy_url.as_deref()
59 }
60
61 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
62 let mut groups: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
64 for entry in ctx.index.files() {
65 if !self.select_scope.matches(&entry.path) {
66 continue;
67 }
68 let tokens = PathTokens::from_path(&entry.path);
69 let key = render_path(&self.key_template, &tokens);
70 if key.is_empty() {
71 continue;
74 }
75 groups.entry(key).or_default().push(entry.path.clone());
76 }
77 let mut violations = Vec::new();
78 for (key, mut paths) in groups {
79 if paths.len() <= 1 {
80 continue;
81 }
82 paths.sort();
83 let anchor = paths[0].clone();
84 let msg = self.format_message(&key, &paths);
85 violations.push(Violation::new(msg).with_path(anchor));
86 }
87 Ok(violations)
88 }
89}
90
91impl UniqueByRule {
92 fn format_message(&self, key: &str, paths: &[PathBuf]) -> String {
93 let paths_joined = paths
94 .iter()
95 .map(|p| p.display().to_string())
96 .collect::<Vec<_>>()
97 .join(", ");
98 if let Some(user) = self.message.as_deref() {
99 let key_str = key.to_string();
100 let paths_str = paths_joined.clone();
101 let count = paths.len().to_string();
102 return render_message(user, |ns, k| match (ns, k) {
103 ("ctx", "key") => Some(key_str.clone()),
104 ("ctx", "paths") => Some(paths_str.clone()),
105 ("ctx", "count") => Some(count.clone()),
106 _ => None,
107 });
108 }
109 format!(
110 "duplicate key {:?} shared by {} file(s): {}",
111 key,
112 paths.len(),
113 paths_joined,
114 )
115 }
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 if opts.key.trim().is_empty() {
123 return Err(Error::rule_config(
124 &spec.id,
125 "unique_by `key` must not be empty",
126 ));
127 }
128 let select_scope = Scope::from_patterns(&[opts.select])?;
129 Ok(Box::new(UniqueByRule {
130 id: spec.id.clone(),
131 level: spec.level,
132 policy_url: spec.policy_url.clone(),
133 message: spec.message.clone(),
134 select_scope,
135 key_template: opts.key,
136 }))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use alint_core::{FileEntry, FileIndex};
143 use std::path::Path;
144
145 fn index(files: &[&str]) -> FileIndex {
146 FileIndex {
147 entries: files
148 .iter()
149 .map(|p| FileEntry {
150 path: PathBuf::from(p),
151 is_dir: false,
152 size: 1,
153 })
154 .collect(),
155 }
156 }
157
158 fn rule(select: &str, key: &str) -> UniqueByRule {
159 UniqueByRule {
160 id: "t".into(),
161 level: Level::Error,
162 policy_url: None,
163 message: None,
164 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
165 key_template: key.to_string(),
166 }
167 }
168
169 fn eval(rule: &UniqueByRule, files: &[&str]) -> Vec<Violation> {
170 let idx = index(files);
171 let ctx = Context {
172 root: Path::new("/"),
173 index: &idx,
174 registry: None,
175 facts: None,
176 vars: None,
177 };
178 rule.evaluate(&ctx).unwrap()
179 }
180
181 #[test]
182 fn passes_when_every_key_unique() {
183 let r = rule("**/*.rs", "{stem}");
184 let v = eval(&r, &["src/foo.rs", "src/bar.rs", "tests/baz.rs"]);
185 assert!(v.is_empty(), "unexpected: {v:?}");
186 }
187
188 #[test]
189 fn flags_stem_collision() {
190 let r = rule("**/*.rs", "{stem}");
191 let v = eval(&r, &["src/mod1/foo.rs", "src/mod2/foo.rs"]);
192 assert_eq!(v.len(), 1);
193 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/mod1/foo.rs")));
195 assert!(v[0].message.contains("src/mod1/foo.rs"));
196 assert!(v[0].message.contains("src/mod2/foo.rs"));
197 }
198
199 #[test]
200 fn one_violation_per_group_regardless_of_group_size() {
201 let r = rule("**/*.rs", "{stem}");
202 let v = eval(
203 &r,
204 &[
205 "src/a/foo.rs",
206 "src/b/foo.rs",
207 "src/c/foo.rs", "src/bar.rs", ],
210 );
211 assert_eq!(v.len(), 1);
212 assert!(v[0].message.contains('3'));
213 }
214
215 #[test]
216 fn multiple_independent_groups() {
217 let r = rule("**/*.rs", "{stem}");
218 let v = eval(
219 &r,
220 &[
221 "src/a/foo.rs",
222 "src/b/foo.rs", "tests/bar.rs",
224 "integration/bar.rs", "src/solo.rs",
226 ],
227 );
228 assert_eq!(v.len(), 2);
229 }
230
231 #[test]
232 fn default_key_is_basename() {
233 let r = UniqueByRule {
236 id: "t".into(),
237 level: Level::Error,
238 policy_url: None,
239 message: None,
240 select_scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
241 key_template: default_key(),
242 };
243 let v = eval(&r, &["src/a/mod.rs", "src/b/mod.rs"]);
244 assert_eq!(v.len(), 1);
245 }
246
247 #[test]
248 fn different_extensions_same_stem_are_not_colliding_by_basename() {
249 let r = UniqueByRule {
250 id: "t".into(),
251 level: Level::Error,
252 policy_url: None,
253 message: None,
254 select_scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
255 key_template: default_key(),
256 };
257 let v = eval(&r, &["src/foo.rs", "src/foo.md"]);
258 assert!(v.is_empty());
259 }
260
261 #[test]
262 fn empty_key_rendering_skips_entry() {
263 let r = rule("*.md", "{parent_name}");
265 let v = eval(&r, &["README.md", "CHANGELOG.md"]);
266 assert!(v.is_empty());
267 }
268
269 #[test]
270 fn message_template_substitution() {
271 let r = UniqueByRule {
272 id: "t".into(),
273 level: Level::Error,
274 policy_url: None,
275 message: Some("{{ctx.count}} files share stem {{ctx.key}}".into()),
276 select_scope: Scope::from_patterns(&["**/*.rs".to_string()]).unwrap(),
277 key_template: "{stem}".into(),
278 };
279 let v = eval(&r, &["a/foo.rs", "b/foo.rs"]);
280 assert_eq!(v.len(), 1);
281 assert_eq!(v[0].message, "2 files share stem foo");
282 }
283}