use std::collections::BTreeMap;
use std::path::PathBuf;
use alint_core::template::{PathTokens, render_message, render_path};
use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
select: String,
#[serde(default = "default_key")]
key: String,
}
fn default_key() -> String {
"{basename}".to_string()
}
#[derive(Debug)]
pub struct UniqueByRule {
id: String,
level: Level,
policy_url: Option<String>,
message: Option<String>,
select_scope: Scope,
key_template: String,
}
impl Rule for UniqueByRule {
fn id(&self) -> &str {
&self.id
}
fn level(&self) -> Level {
self.level
}
fn policy_url(&self) -> Option<&str> {
self.policy_url.as_deref()
}
fn requires_full_index(&self) -> bool {
true
}
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
let mut groups: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
for entry in ctx.index.files() {
if !self.select_scope.matches(&entry.path) {
continue;
}
let tokens = PathTokens::from_path(&entry.path);
let key = render_path(&self.key_template, &tokens);
if key.is_empty() {
continue;
}
groups.entry(key).or_default().push(entry.path.clone());
}
let mut violations = Vec::new();
for (key, mut paths) in groups {
if paths.len() <= 1 {
continue;
}
paths.sort();
let anchor = paths[0].clone();
let msg = self.format_message(&key, &paths);
violations.push(Violation::new(msg).with_path(anchor));
}
Ok(violations)
}
}
impl UniqueByRule {
fn format_message(&self, key: &str, paths: &[PathBuf]) -> String {
let paths_joined = paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
if let Some(user) = self.message.as_deref() {
let key_str = key.to_string();
let paths_str = paths_joined.clone();
let count = paths.len().to_string();
return render_message(user, |ns, k| match (ns, k) {
("ctx", "key") => Some(key_str.clone()),
("ctx", "paths") => Some(paths_str.clone()),
("ctx", "count") => Some(count.clone()),
_ => None,
});
}
format!(
"duplicate key {:?} shared by {} file(s): {}",
key,
paths.len(),
paths_joined,
)
}
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
if opts.key.trim().is_empty() {
return Err(Error::rule_config(
&spec.id,
"unique_by `key` must not be empty",
));
}
let select_scope = Scope::from_patterns(&[opts.select])?;
Ok(Box::new(UniqueByRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
message: spec.message.clone(),
select_scope,
key_template: opts.key,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use alint_core::{FileEntry, FileIndex};
use std::path::Path;
fn index(files: &[&str]) -> FileIndex {
FileIndex {
entries: files
.iter()
.map(|p| FileEntry {
path: PathBuf::from(p),
is_dir: false,
size: 1,
})
.collect(),
}
}
fn rule(select: &str, key: &str) -> UniqueByRule {
UniqueByRule {
id: "t".into(),
level: Level::Error,
policy_url: None,
message: None,
select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
key_template: key.to_string(),
}
}
fn eval(rule: &UniqueByRule, files: &[&str]) -> Vec<Violation> {
let idx = index(files);
let ctx = Context {
root: Path::new("/"),
index: &idx,
registry: None,
facts: None,
vars: None,
git_tracked: None,
git_blame: None,
};
rule.evaluate(&ctx).unwrap()
}
#[test]
fn passes_when_every_key_unique() {
let r = rule("**/*.rs", "{stem}");
let v = eval(&r, &["src/foo.rs", "src/bar.rs", "tests/baz.rs"]);
assert!(v.is_empty(), "unexpected: {v:?}");
}
#[test]
fn flags_stem_collision() {
let r = rule("**/*.rs", "{stem}");
let v = eval(&r, &["src/mod1/foo.rs", "src/mod2/foo.rs"]);
assert_eq!(v.len(), 1);
assert_eq!(v[0].path.as_deref(), Some(Path::new("src/mod1/foo.rs")));
assert!(v[0].message.contains("src/mod1/foo.rs"));
assert!(v[0].message.contains("src/mod2/foo.rs"));
}
#[test]
fn one_violation_per_group_regardless_of_group_size() {
let r = rule("**/*.rs", "{stem}");
let v = eval(
&r,
&[
"src/a/foo.rs",
"src/b/foo.rs",
"src/c/foo.rs", "src/bar.rs", ],
);
assert_eq!(v.len(), 1);
assert!(v[0].message.contains('3'));
}
#[test]
fn multiple_independent_groups() {
let r = rule("**/*.rs", "{stem}");
let v = eval(
&r,
&[
"src/a/foo.rs",
"src/b/foo.rs", "tests/bar.rs",
"integration/bar.rs", "src/solo.rs",
],
);
assert_eq!(v.len(), 2);
}
#[test]
fn default_key_is_basename() {
let r = UniqueByRule {
id: "t".into(),
level: Level::Error,
policy_url: None,
message: None,
select_scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
key_template: default_key(),
};
let v = eval(&r, &["src/a/mod.rs", "src/b/mod.rs"]);
assert_eq!(v.len(), 1);
}
#[test]
fn different_extensions_same_stem_are_not_colliding_by_basename() {
let r = UniqueByRule {
id: "t".into(),
level: Level::Error,
policy_url: None,
message: None,
select_scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
key_template: default_key(),
};
let v = eval(&r, &["src/foo.rs", "src/foo.md"]);
assert!(v.is_empty());
}
#[test]
fn empty_key_rendering_skips_entry() {
let r = rule("*.md", "{parent_name}");
let v = eval(&r, &["README.md", "CHANGELOG.md"]);
assert!(v.is_empty());
}
#[test]
fn message_template_substitution() {
let r = UniqueByRule {
id: "t".into(),
level: Level::Error,
policy_url: None,
message: Some("{{ctx.count}} files share stem {{ctx.key}}".into()),
select_scope: Scope::from_patterns(&["**/*.rs".to_string()]).unwrap(),
key_template: "{stem}".into(),
};
let v = eval(&r, &["a/foo.rs", "b/foo.rs"]);
assert_eq!(v.len(), 1);
assert_eq!(v[0].message, "2 files share stem foo");
}
}