Skip to main content

alint_rules/
unique_by.rs

1//! `unique_by` — flag any group of files (matching `select:`) that share
2//! the same rendered `key`. The key is a path template evaluated per
3//! matched file; default is `{basename}` (catches any two files with the
4//! same name regardless of directory).
5//!
6//! Canonical shape — every Rust source stem must be unique repo-wide:
7//!
8//! ```yaml
9//! - id: unique-rs-stems
10//!   kind: unique_by
11//!   select: "**/*.rs"
12//!   key: "{stem}"
13//!   level: warning
14//! ```
15//!
16//! Violations are emitted **one per collision group**, anchored on the
17//! lexicographically-first path of the group; the message enumerates
18//! every colliding file. For groups of N, that is one violation (not N),
19//! because the collision is a single fact.
20
21use 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        // BTreeMap gives a stable (sorted) iteration order → deterministic output.
63        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                // Skip files whose key renders to the empty string — likely a
72                // missing component like `{parent_name}` on a root-level file.
73                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        // Anchor is lex-smallest of the collision group.
194        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", // 3-way collision on "foo"
208                "src/bar.rs",   // unique
209            ],
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", // group "foo"
223                "tests/bar.rs",
224                "integration/bar.rs", // group "bar"
225                "src/solo.rs",
226            ],
227        );
228        assert_eq!(v.len(), 2);
229    }
230
231    #[test]
232    fn default_key_is_basename() {
233        // No key option = default {basename}: collisions require identical
234        // filename including extension.
235        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        // `{parent_name}` on a root-level file renders to "" — excluded.
264        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}