1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use std::collections::HashSet;
4
5#[derive(Debug)]
11pub struct BannedDependencyRule {
12 id: String,
13 severity: Severity,
14 message: String,
15 suggest: Option<String>,
16 glob: Option<String>,
17 packages: HashSet<String>,
18 manifest: String,
19}
20
21const DEP_SECTIONS: &[&str] = &[
23 "dependencies",
24 "devDependencies",
25 "peerDependencies",
26 "optionalDependencies",
27];
28
29impl BannedDependencyRule {
30 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
31 if config.packages.is_empty() {
32 return Err(RuleBuildError::MissingField(
33 config.id.clone(),
34 "packages",
35 ));
36 }
37
38 let packages: HashSet<String> = config.packages.iter().cloned().collect();
39 let manifest = config
40 .manifest
41 .as_deref()
42 .unwrap_or("package.json")
43 .to_string();
44
45 let glob = config
47 .glob
48 .clone()
49 .or_else(|| Some(format!("**/{}", manifest)));
50
51 Ok(Self {
52 id: config.id.clone(),
53 severity: config.severity,
54 message: config.message.clone(),
55 suggest: config.suggest.clone(),
56 glob,
57 packages,
58 manifest,
59 })
60 }
61}
62
63impl Rule for BannedDependencyRule {
64 fn id(&self) -> &str {
65 &self.id
66 }
67
68 fn severity(&self) -> Severity {
69 self.severity
70 }
71
72 fn file_glob(&self) -> Option<&str> {
73 self.glob.as_deref()
74 }
75
76 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
77 let file_name = ctx
79 .file_path
80 .file_name()
81 .and_then(|n| n.to_str())
82 .unwrap_or("");
83
84 if file_name != self.manifest {
85 return Vec::new();
86 }
87
88 let json: serde_json::Value = match serde_json::from_str(ctx.content) {
89 Ok(v) => v,
90 Err(_) => return Vec::new(), };
92
93 let mut violations = Vec::new();
94
95 for section in DEP_SECTIONS {
96 if let Some(deps) = json.get(section).and_then(|v| v.as_object()) {
97 for pkg_name in deps.keys() {
98 if self.packages.contains(pkg_name) {
99 let line_num = find_line_number(ctx.content, pkg_name, section);
101
102 violations.push(Violation {
103 rule_id: self.id.clone(),
104 severity: self.severity,
105 file: ctx.file_path.to_path_buf(),
106 line: line_num,
107 column: None,
108 message: format!(
109 "{}: '{}' in {}",
110 self.message, pkg_name, section
111 ),
112 suggest: self.suggest.clone(),
113 source_line: line_num.and_then(|n| {
114 ctx.content.lines().nth(n - 1).map(|l| l.to_string())
115 }),
116 fix: None,
117 });
118 }
119 }
120 }
121 }
122
123 violations
124 }
125}
126
127fn find_line_number(content: &str, pkg_name: &str, section: &str) -> Option<usize> {
129 let needle = format!(r#""{}""#, pkg_name);
130 let section_needle = format!(r#""{}""#, section);
131
132 let mut in_section = false;
133 let mut brace_depth = 0;
134
135 for (idx, line) in content.lines().enumerate() {
136 if line.contains(§ion_needle) {
137 in_section = true;
138 brace_depth = 0;
139 continue;
140 }
141
142 if in_section {
143 brace_depth += line.matches('{').count() as i32;
144 brace_depth -= line.matches('}').count() as i32;
145
146 if brace_depth < 0 {
147 in_section = false;
148 continue;
149 }
150
151 if line.contains(&needle) {
152 return Some(idx + 1);
153 }
154 }
155 }
156
157 for (idx, line) in content.lines().enumerate() {
159 if line.contains(&needle) {
160 return Some(idx + 1);
161 }
162 }
163
164 None
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::path::Path;
171
172 fn make_rule(packages: Vec<&str>) -> BannedDependencyRule {
173 let config = RuleConfig {
174 id: "test-banned-dep".into(),
175 severity: Severity::Error,
176 message: "banned dependency".into(),
177 suggest: Some("remove this package".into()),
178 packages: packages.into_iter().map(|s| s.to_string()).collect(),
179 ..Default::default()
180 };
181 BannedDependencyRule::new(&config).unwrap()
182 }
183
184 fn check(rule: &BannedDependencyRule, content: &str) -> Vec<Violation> {
185 let ctx = ScanContext {
186 file_path: Path::new("package.json"),
187 content,
188 };
189 rule.check_file(&ctx)
190 }
191
192 #[test]
193 fn detects_dependency() {
194 let rule = make_rule(vec!["bootstrap"]);
195 let content = r#"{
196 "dependencies": {
197 "bootstrap": "^5.0.0",
198 "react": "^18.0.0"
199 }
200}"#;
201 let violations = check(&rule, content);
202 assert_eq!(violations.len(), 1);
203 assert!(violations[0].message.contains("bootstrap"));
204 assert!(violations[0].message.contains("dependencies"));
205 }
206
207 #[test]
208 fn detects_dev_dependency() {
209 let rule = make_rule(vec!["bootstrap"]);
210 let content = r#"{
211 "devDependencies": {
212 "bootstrap": "^5.0.0"
213 }
214}"#;
215 let violations = check(&rule, content);
216 assert_eq!(violations.len(), 1);
217 assert!(violations[0].message.contains("devDependencies"));
218 }
219
220 #[test]
221 fn detects_multiple_banned_packages() {
222 let rule = make_rule(vec!["bootstrap", "@mui/material"]);
223 let content = r#"{
224 "dependencies": {
225 "bootstrap": "^5.0.0",
226 "@mui/material": "^5.0.0",
227 "react": "^18.0.0"
228 }
229}"#;
230 let violations = check(&rule, content);
231 assert_eq!(violations.len(), 2);
232 }
233
234 #[test]
235 fn no_match_on_safe_deps() {
236 let rule = make_rule(vec!["bootstrap"]);
237 let content = r#"{
238 "dependencies": {
239 "react": "^18.0.0",
240 "next": "^14.0.0"
241 }
242}"#;
243 let violations = check(&rule, content);
244 assert!(violations.is_empty());
245 }
246
247 #[test]
248 fn skips_non_manifest_files() {
249 let rule = make_rule(vec!["bootstrap"]);
250 let ctx = ScanContext {
251 file_path: Path::new("src/component.tsx"),
252 content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
253 };
254 let violations = rule.check_file(&ctx);
255 assert!(violations.is_empty());
256 }
257
258 #[test]
259 fn skips_malformed_json() {
260 let rule = make_rule(vec!["bootstrap"]);
261 let violations = check(&rule, "not valid json {{{");
262 assert!(violations.is_empty());
263 }
264
265 #[test]
266 fn finds_line_numbers() {
267 let rule = make_rule(vec!["bootstrap"]);
268 let content = r#"{
269 "name": "my-app",
270 "dependencies": {
271 "bootstrap": "^5.0.0"
272 }
273}"#;
274 let violations = check(&rule, content);
275 assert_eq!(violations.len(), 1);
276 assert_eq!(violations[0].line, Some(4));
277 }
278
279 #[test]
280 fn missing_packages_error() {
281 let config = RuleConfig {
282 id: "test".into(),
283 severity: Severity::Error,
284 message: "test".into(),
285 ..Default::default()
286 };
287 let err = BannedDependencyRule::new(&config).unwrap_err();
288 assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
289 }
290
291 #[test]
292 fn custom_manifest_name() {
293 let config = RuleConfig {
294 id: "test-banned-dep".into(),
295 severity: Severity::Error,
296 message: "banned dependency".into(),
297 packages: vec!["bootstrap".to_string()],
298 manifest: Some("bower.json".to_string()),
299 ..Default::default()
300 };
301 let rule = BannedDependencyRule::new(&config).unwrap();
302
303 let ctx = ScanContext {
305 file_path: Path::new("package.json"),
306 content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
307 };
308 assert!(rule.check_file(&ctx).is_empty());
309
310 let ctx = ScanContext {
312 file_path: Path::new("bower.json"),
313 content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
314 };
315 assert_eq!(rule.check_file(&ctx).len(), 1);
316 }
317}