use crate::config::{RuleConfig, Severity};
use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
use std::collections::HashSet;
#[derive(Debug)]
pub struct BannedDependencyRule {
id: String,
severity: Severity,
message: String,
suggest: Option<String>,
glob: Option<String>,
packages: HashSet<String>,
manifest: String,
}
const DEP_SECTIONS: &[&str] = &[
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
impl BannedDependencyRule {
pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
if config.packages.is_empty() {
return Err(RuleBuildError::MissingField(
config.id.clone(),
"packages",
));
}
let packages: HashSet<String> = config.packages.iter().cloned().collect();
let manifest = config
.manifest
.as_deref()
.unwrap_or("package.json")
.to_string();
let glob = config
.glob
.clone()
.or_else(|| Some(format!("**/{}", manifest)));
Ok(Self {
id: config.id.clone(),
severity: config.severity,
message: config.message.clone(),
suggest: config.suggest.clone(),
glob,
packages,
manifest,
})
}
}
impl Rule for BannedDependencyRule {
fn id(&self) -> &str {
&self.id
}
fn severity(&self) -> Severity {
self.severity
}
fn file_glob(&self) -> Option<&str> {
self.glob.as_deref()
}
fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
let file_name = ctx
.file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if file_name != self.manifest {
return Vec::new();
}
let json: serde_json::Value = match serde_json::from_str(ctx.content) {
Ok(v) => v,
Err(_) => return Vec::new(), };
let mut violations = Vec::new();
for section in DEP_SECTIONS {
if let Some(deps) = json.get(section).and_then(|v| v.as_object()) {
for pkg_name in deps.keys() {
if self.packages.contains(pkg_name) {
let line_num = find_line_number(ctx.content, pkg_name, section);
violations.push(Violation {
rule_id: self.id.clone(),
severity: self.severity,
file: ctx.file_path.to_path_buf(),
line: line_num,
column: None,
message: format!(
"{}: '{}' in {}",
self.message, pkg_name, section
),
suggest: self.suggest.clone(),
source_line: line_num.and_then(|n| {
ctx.content.lines().nth(n - 1).map(|l| l.to_string())
}),
fix: None,
});
}
}
}
}
violations
}
}
fn find_line_number(content: &str, pkg_name: &str, section: &str) -> Option<usize> {
let needle = format!(r#""{}""#, pkg_name);
let section_needle = format!(r#""{}""#, section);
let mut in_section = false;
let mut brace_depth = 0;
for (idx, line) in content.lines().enumerate() {
if line.contains(§ion_needle) {
in_section = true;
brace_depth = 0;
continue;
}
if in_section {
brace_depth += line.matches('{').count() as i32;
brace_depth -= line.matches('}').count() as i32;
if brace_depth < 0 {
in_section = false;
continue;
}
if line.contains(&needle) {
return Some(idx + 1);
}
}
}
for (idx, line) in content.lines().enumerate() {
if line.contains(&needle) {
return Some(idx + 1);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn make_rule(packages: Vec<&str>) -> BannedDependencyRule {
let config = RuleConfig {
id: "test-banned-dep".into(),
severity: Severity::Error,
message: "banned dependency".into(),
suggest: Some("remove this package".into()),
packages: packages.into_iter().map(|s| s.to_string()).collect(),
..Default::default()
};
BannedDependencyRule::new(&config).unwrap()
}
fn check(rule: &BannedDependencyRule, content: &str) -> Vec<Violation> {
let ctx = ScanContext {
file_path: Path::new("package.json"),
content,
};
rule.check_file(&ctx)
}
#[test]
fn detects_dependency() {
let rule = make_rule(vec!["bootstrap"]);
let content = r#"{
"dependencies": {
"bootstrap": "^5.0.0",
"react": "^18.0.0"
}
}"#;
let violations = check(&rule, content);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("bootstrap"));
assert!(violations[0].message.contains("dependencies"));
}
#[test]
fn detects_dev_dependency() {
let rule = make_rule(vec!["bootstrap"]);
let content = r#"{
"devDependencies": {
"bootstrap": "^5.0.0"
}
}"#;
let violations = check(&rule, content);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("devDependencies"));
}
#[test]
fn detects_multiple_banned_packages() {
let rule = make_rule(vec!["bootstrap", "@mui/material"]);
let content = r#"{
"dependencies": {
"bootstrap": "^5.0.0",
"@mui/material": "^5.0.0",
"react": "^18.0.0"
}
}"#;
let violations = check(&rule, content);
assert_eq!(violations.len(), 2);
}
#[test]
fn no_match_on_safe_deps() {
let rule = make_rule(vec!["bootstrap"]);
let content = r#"{
"dependencies": {
"react": "^18.0.0",
"next": "^14.0.0"
}
}"#;
let violations = check(&rule, content);
assert!(violations.is_empty());
}
#[test]
fn skips_non_manifest_files() {
let rule = make_rule(vec!["bootstrap"]);
let ctx = ScanContext {
file_path: Path::new("src/component.tsx"),
content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
};
let violations = rule.check_file(&ctx);
assert!(violations.is_empty());
}
#[test]
fn skips_malformed_json() {
let rule = make_rule(vec!["bootstrap"]);
let violations = check(&rule, "not valid json {{{");
assert!(violations.is_empty());
}
#[test]
fn finds_line_numbers() {
let rule = make_rule(vec!["bootstrap"]);
let content = r#"{
"name": "my-app",
"dependencies": {
"bootstrap": "^5.0.0"
}
}"#;
let violations = check(&rule, content);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, Some(4));
}
#[test]
fn missing_packages_error() {
let config = RuleConfig {
id: "test".into(),
severity: Severity::Error,
message: "test".into(),
..Default::default()
};
let err = BannedDependencyRule::new(&config).unwrap_err();
assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
}
#[test]
fn custom_manifest_name() {
let config = RuleConfig {
id: "test-banned-dep".into(),
severity: Severity::Error,
message: "banned dependency".into(),
packages: vec!["bootstrap".to_string()],
manifest: Some("bower.json".to_string()),
..Default::default()
};
let rule = BannedDependencyRule::new(&config).unwrap();
let ctx = ScanContext {
file_path: Path::new("package.json"),
content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
};
assert!(rule.check_file(&ctx).is_empty());
let ctx = ScanContext {
file_path: Path::new("bower.json"),
content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
};
assert_eq!(rule.check_file(&ctx).len(), 1);
}
}