Skip to main content

alint_rules/
no_case_conflicts.rs

1//! `no_case_conflicts` — flag two paths that differ only by
2//! case (e.g. `README.md` + `readme.md`). Such pairs cannot
3//! coexist on case-insensitive filesystems (macOS HFS+/APFS
4//! default, Windows NTFS in its default mode), so committing
5//! them breaks checkouts for those developers.
6//!
7//! Check-only — renaming which one to keep is a human decision.
8
9use std::collections::BTreeMap;
10
11use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
12
13#[derive(Debug)]
14pub struct NoCaseConflictsRule {
15    id: String,
16    level: Level,
17    policy_url: Option<String>,
18    message: Option<String>,
19    scope: Scope,
20}
21
22impl Rule for NoCaseConflictsRule {
23    fn id(&self) -> &str {
24        &self.id
25    }
26    fn level(&self) -> Level {
27        self.level
28    }
29    fn policy_url(&self) -> Option<&str> {
30        self.policy_url.as_deref()
31    }
32
33    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
34        // Group paths by their lowercased form. Storing
35        // `Arc<Path>` here lets us hand the same allocation to
36        // every violation later without re-cloning bytes.
37        let mut groups: BTreeMap<String, Vec<std::sync::Arc<std::path::Path>>> = BTreeMap::new();
38        for entry in ctx.index.files() {
39            if !self.scope.matches(&entry.path, ctx.index) {
40                continue;
41            }
42            let Some(as_str) = entry.path.to_str() else {
43                continue;
44            };
45            groups
46                .entry(as_str.to_ascii_lowercase())
47                .or_default()
48                .push(entry.path.clone());
49        }
50        let mut violations = Vec::new();
51        for (_lower, paths) in groups {
52            if paths.len() < 2 {
53                continue;
54            }
55            let names: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
56            for p in &paths {
57                let msg = self.message.clone().unwrap_or_else(|| {
58                    format!(
59                        "case-insensitive collision: {} (collides with: {})",
60                        p.display(),
61                        names
62                            .iter()
63                            .filter(|n| *n != &p.display().to_string())
64                            .cloned()
65                            .collect::<Vec<_>>()
66                            .join(", ")
67                    )
68                });
69                violations.push(Violation::new(msg).with_path(p.clone()));
70            }
71        }
72        Ok(violations)
73    }
74}
75
76pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
77    let _paths = spec.paths.as_ref().ok_or_else(|| {
78        Error::rule_config(
79            &spec.id,
80            "no_case_conflicts requires a `paths` field (often `\"**\"`)",
81        )
82    })?;
83    if spec.fix.is_some() {
84        return Err(Error::rule_config(
85            &spec.id,
86            "no_case_conflicts has no fix op — renaming which path to keep is a human decision",
87        ));
88    }
89    Ok(Box::new(NoCaseConflictsRule {
90        id: spec.id.clone(),
91        level: spec.level,
92        policy_url: spec.policy_url.clone(),
93        message: spec.message.clone(),
94        scope: Scope::from_spec(spec)?,
95    }))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::test_support::{ctx, index, spec_yaml};
102    use std::path::Path;
103
104    #[test]
105    fn build_rejects_missing_paths_field() {
106        let spec = spec_yaml(
107            "id: t\n\
108             kind: no_case_conflicts\n\
109             level: warning\n",
110        );
111        assert!(build(&spec).is_err());
112    }
113
114    #[test]
115    fn build_rejects_fix_block() {
116        let spec = spec_yaml(
117            "id: t\n\
118             kind: no_case_conflicts\n\
119             paths: \"**\"\n\
120             level: warning\n\
121             fix:\n  \
122               file_remove: {}\n",
123        );
124        assert!(build(&spec).is_err());
125    }
126
127    #[test]
128    fn evaluate_passes_when_paths_unique_after_lowercase() {
129        let spec = spec_yaml(
130            "id: t\n\
131             kind: no_case_conflicts\n\
132             paths: \"**\"\n\
133             level: warning\n",
134        );
135        let rule = build(&spec).unwrap();
136        let i = index(&["README.md", "src/main.rs", "Cargo.toml"]);
137        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
138        assert!(v.is_empty());
139    }
140
141    #[test]
142    fn evaluate_fires_one_violation_per_collision_member() {
143        let spec = spec_yaml(
144            "id: t\n\
145             kind: no_case_conflicts\n\
146             paths: \"**\"\n\
147             level: warning\n",
148        );
149        let rule = build(&spec).unwrap();
150        // README.md and readme.md collide → both emitted.
151        let i = index(&["README.md", "readme.md", "Cargo.toml"]);
152        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
153        assert_eq!(v.len(), 2, "two collision members should fire");
154    }
155
156    #[test]
157    fn evaluate_fires_on_three_way_collision() {
158        let spec = spec_yaml(
159            "id: t\n\
160             kind: no_case_conflicts\n\
161             paths: \"**\"\n\
162             level: warning\n",
163        );
164        let rule = build(&spec).unwrap();
165        let i = index(&["README.md", "readme.md", "ReadMe.md"]);
166        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
167        assert_eq!(v.len(), 3, "three collision members should fire");
168    }
169
170    #[test]
171    fn scope_filter_narrows() {
172        // Two collision pairs: one inside `pkg/` (scoped in via
173        // marker.lock) and one inside `other/` (filtered out).
174        // Only the in-scope pair should fire.
175        let spec = spec_yaml(
176            "id: t\n\
177             kind: no_case_conflicts\n\
178             paths: \"**\"\n\
179             scope_filter:\n  \
180               has_ancestor: marker.lock\n\
181             level: warning\n",
182        );
183        let rule = build(&spec).unwrap();
184        let i = index(&[
185            "pkg/marker.lock",
186            "pkg/README.md",
187            "pkg/readme.md",
188            "other/README.md",
189            "other/readme.md",
190        ]);
191        let v = rule.evaluate(&ctx(Path::new("/fake"), &i)).unwrap();
192        assert_eq!(v.len(), 2, "only the pkg/ pair should fire: {v:?}");
193        for vio in &v {
194            assert!(
195                vio.path.as_deref().is_some_and(|p| p.starts_with("pkg/")),
196                "unexpected path: {:?}",
197                vio.path
198            );
199        }
200    }
201}