Skip to main content

garbage_code_hunter/deps_shamer/
rules.rs

1//! Dependency analysis rules.
2//!
3//! Each rule checks for a specific anti-pattern in dependency management.
4
5use super::types::{DepFile, DepIssue, DepSource, Ecosystem, Severity};
6
7/// A rule that checks dependency files for issues.
8pub trait DepRule: Send + Sync {
9    fn id(&self) -> &str;
10    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue>;
11}
12
13/// Too many dependencies.
14pub struct TooManyDepsRule {
15    pub threshold: usize,
16}
17
18impl DepRule for TooManyDepsRule {
19    fn id(&self) -> &str {
20        "too-many-deps"
21    }
22
23    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
24        let total = dep_file.dependencies.len();
25        if total > self.threshold {
26            vec![DepIssue {
27                rule_id: self.id().to_string(),
28                severity: Severity::Medium,
29                message: format!(
30                    "{} dependencies? Are you building a supermarket or a software project?",
31                    total
32                ),
33                dep_name: None,
34            }]
35        } else {
36            vec![]
37        }
38    }
39}
40
41/// Git-based dependencies.
42pub struct GitDepsRule;
43
44impl DepRule for GitDepsRule {
45    fn id(&self) -> &str {
46        "git-deps"
47    }
48
49    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
50        dep_file
51            .dependencies
52            .iter()
53            .filter_map(|dep| {
54                if let DepSource::Git { url } = &dep.source {
55                    Some(DepIssue {
56                        rule_id: self.id().to_string(),
57                        severity: Severity::Medium,
58                        message: format!(
59                            "Directly referencing git repo '{}' — don't trust package managers?",
60                            url
61                        ),
62                        dep_name: Some(dep.name.clone()),
63                    })
64                } else {
65                    None
66                }
67            })
68            .collect()
69    }
70}
71
72/// Wildcard or star version.
73pub struct WildcardVersionRule;
74
75impl DepRule for WildcardVersionRule {
76    fn id(&self) -> &str {
77        "wildcard-version"
78    }
79
80    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
81        dep_file
82            .dependencies
83            .iter()
84            .filter_map(|dep| {
85                let v = dep.version.trim();
86                if v == "*" || v == ">=0" || v.is_empty() {
87                    Some(DepIssue {
88                        rule_id: self.id().to_string(),
89                        severity: Severity::High,
90                        message: format!(
91                            "Version '{}' for '{}' — enjoy your daily breaking changes?",
92                            v, dep.name
93                        ),
94                        dep_name: Some(dep.name.clone()),
95                    })
96                } else {
97                    None
98                }
99            })
100            .collect()
101    }
102}
103
104/// Pre-release / alpha / beta version in non-dev dependency.
105pub struct PreReleaseRule;
106
107impl DepRule for PreReleaseRule {
108    fn id(&self) -> &str {
109        "pre-release"
110    }
111
112    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
113        dep_file
114            .dependencies
115            .iter()
116            .filter(|dep| !dep.is_dev)
117            .filter_map(|dep| {
118                let v = dep.version.to_lowercase();
119                if v.contains("alpha")
120                    || v.contains("beta")
121                    || v.contains("rc")
122                    || v.contains("pre")
123                    || v.contains("snapshot")
124                {
125                    Some(DepIssue {
126                        rule_id: self.id().to_string(),
127                        severity: Severity::Medium,
128                        message: format!(
129                            "Production dependency '{}' uses pre-release version '{}' — brave!",
130                            dep.name, dep.version
131                        ),
132                        dep_name: Some(dep.name.clone()),
133                    })
134                } else {
135                    None
136                }
137            })
138            .collect()
139    }
140}
141
142/// Deprecated or unmaintained dependency patterns.
143pub struct DeprecatedDepRule {
144    pub ecosystem: Ecosystem,
145}
146
147impl DepRule for DeprecatedDepRule {
148    fn id(&self) -> &str {
149        "deprecated-dep"
150    }
151
152    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
153        let deprecated = match &self.ecosystem {
154            Ecosystem::Rust => vec![
155                "failure",
156                "iron",
157                "nickel",
158                "rustc-serialize",
159                "quickcheck",
160                "tempdir",
161                "toml_query",
162            ],
163            Ecosystem::Node => vec![
164                "request",
165                "bower",
166                "node-uuid",
167                "nomnom",
168                "optimist",
169                "colors",
170                "left-pad",
171            ],
172            _ => vec![],
173        };
174
175        dep_file
176            .dependencies
177            .iter()
178            .filter_map(|dep| {
179                if deprecated.contains(&dep.name.as_str()) {
180                    Some(DepIssue {
181                        rule_id: self.id().to_string(),
182                        severity: Severity::High,
183                        message: format!(
184                            "'{}' is deprecated — are you an archaeologist?",
185                            dep.name
186                        ),
187                        dep_name: Some(dep.name.clone()),
188                    })
189                } else {
190                    None
191                }
192            })
193            .collect()
194    }
195}
196
197/// Duplicate dependencies (same name appearing multiple times).
198pub struct DuplicatedDepRule;
199
200impl DepRule for DuplicatedDepRule {
201    fn id(&self) -> &str {
202        "duplicated-dep"
203    }
204
205    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
206        let mut seen = std::collections::HashSet::new();
207        let mut duplicates = Vec::new();
208
209        for dep in &dep_file.dependencies {
210            if !seen.insert(&dep.name) {
211                duplicates.push(DepIssue {
212                    rule_id: self.id().to_string(),
213                    severity: Severity::Medium,
214                    message: format!(
215                        "'{}' appears more than once — Ctrl+C and Ctrl+V are working overtime?",
216                        dep.name
217                    ),
218                    dep_name: Some(dep.name.clone()),
219                });
220            }
221        }
222
223        duplicates
224    }
225}
226
227/// Too many dev dependencies.
228pub struct TooManyDevDepsRule {
229    pub threshold: usize,
230}
231
232impl DepRule for TooManyDevDepsRule {
233    fn id(&self) -> &str {
234        "too-many-dev-deps"
235    }
236
237    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
238        let dev_count = dep_file.dependencies.iter().filter(|d| d.is_dev).count();
239        if dev_count > self.threshold {
240            vec![DepIssue {
241                rule_id: self.id().to_string(),
242                severity: Severity::Low,
243                message: format!(
244                    "{} dev dependencies — your test setup is heavier than the app itself?",
245                    dev_count
246                ),
247                dep_name: None,
248            }]
249        } else {
250            vec![]
251        }
252    }
253}
254
255/// High ratio of optional dependencies.
256pub struct TooManyOptionalRule {
257    pub ratio_threshold: f64,
258}
259
260impl DepRule for TooManyOptionalRule {
261    fn id(&self) -> &str {
262        "too-many-optional"
263    }
264
265    fn check(&self, dep_file: &DepFile) -> Vec<DepIssue> {
266        let total = dep_file.dependencies.len();
267        if total == 0 {
268            return vec![];
269        }
270        let optional_count = dep_file
271            .dependencies
272            .iter()
273            .filter(|d| d.is_optional)
274            .count();
275        let ratio = optional_count as f64 / total as f64;
276        if ratio > self.ratio_threshold {
277            vec![DepIssue {
278                rule_id: self.id().to_string(),
279                severity: Severity::Low,
280                message: format!(
281                    "{}% of dependencies are optional ({}/{}) — are you sure what you actually need?",
282                    (ratio * 100.0) as usize,
283                    optional_count,
284                    total
285                ),
286                dep_name: None,
287            }]
288        } else {
289            vec![]
290        }
291    }
292}
293
294/// Get all default rules for a given ecosystem.
295pub fn default_rules(ecosystem: &Ecosystem) -> Vec<Box<dyn DepRule>> {
296    vec![
297        Box::new(TooManyDepsRule { threshold: 50 }),
298        Box::new(GitDepsRule),
299        Box::new(WildcardVersionRule),
300        Box::new(PreReleaseRule),
301        Box::new(DeprecatedDepRule {
302            ecosystem: ecosystem.clone(),
303        }),
304        Box::new(DuplicatedDepRule),
305        Box::new(TooManyDevDepsRule { threshold: 20 }),
306        Box::new(TooManyOptionalRule {
307            ratio_threshold: 0.5,
308        }),
309    ]
310}
311
312/// Apply all rules to a dependency file and return issues.
313pub fn check_dep_file(dep_file: &DepFile) -> Vec<DepIssue> {
314    let rules = default_rules(&dep_file.ecosystem);
315    rules.iter().flat_map(|rule| rule.check(dep_file)).collect()
316}
317
318#[cfg(test)]
319mod tests {
320    use super::super::types::Dependency;
321    use super::*;
322
323    fn make_dep(name: &str, version: &str) -> Dependency {
324        Dependency {
325            name: name.to_string(),
326            version: version.to_string(),
327            source: DepSource::Registry,
328            is_dev: false,
329            is_optional: false,
330        }
331    }
332
333    fn make_dep_file(deps: Vec<Dependency>) -> DepFile {
334        DepFile {
335            path: "test.toml".to_string(),
336            ecosystem: Ecosystem::Rust,
337            dependencies: deps,
338        }
339    }
340
341    #[test]
342    fn test_too_many_deps_triggers() {
343        let rule = TooManyDepsRule { threshold: 5 };
344        let deps: Vec<Dependency> = (0..10)
345            .map(|i| make_dep(&format!("dep{}", i), "1.0"))
346            .collect();
347        let dep_file = make_dep_file(deps);
348        let issues = rule.check(&dep_file);
349        assert_eq!(issues.len(), 1);
350        assert!(issues[0].message.contains("10"));
351    }
352
353    #[test]
354    fn test_too_many_deps_no_trigger() {
355        let rule = TooManyDepsRule { threshold: 50 };
356        let deps = vec![make_dep("serde", "1.0")];
357        let dep_file = make_dep_file(deps);
358        let issues = rule.check(&dep_file);
359        assert!(issues.is_empty());
360    }
361
362    #[test]
363    fn test_git_deps_detected() {
364        let rule = GitDepsRule;
365        let dep_file = make_dep_file(vec![
366            make_dep("serde", "1.0"),
367            Dependency {
368                name: "my-lib".to_string(),
369                version: "main".to_string(),
370                source: DepSource::Git {
371                    url: "https://github.com/foo/bar".to_string(),
372                },
373                is_dev: false,
374                is_optional: false,
375            },
376        ]);
377        let issues = rule.check(&dep_file);
378        assert_eq!(issues.len(), 1);
379        assert!(issues[0].dep_name.as_deref() == Some("my-lib"));
380    }
381
382    #[test]
383    fn test_wildcard_version_detected() {
384        let rule = WildcardVersionRule;
385        let dep_file = make_dep_file(vec![
386            make_dep("ok-dep", "1.0"),
387            make_dep("bad-dep", "*"),
388            make_dep("also-bad", ">=0"),
389        ]);
390        let issues = rule.check(&dep_file);
391        assert_eq!(issues.len(), 2);
392    }
393
394    #[test]
395    fn test_pre_release_detected() {
396        let rule = PreReleaseRule;
397        let dep_file = make_dep_file(vec![
398            make_dep("stable", "1.0"),
399            make_dep("beta-pkg", "2.0.0-beta.1"),
400            make_dep("alpha-pkg", "1.0.0-alpha"),
401        ]);
402        let issues = rule.check(&dep_file);
403        assert_eq!(issues.len(), 2);
404    }
405
406    #[test]
407    fn test_pre_release_ignores_dev_deps() {
408        let rule = PreReleaseRule;
409        let mut dep = make_dep("dev-beta", "1.0.0-beta");
410        dep.is_dev = true;
411        let dep_file = make_dep_file(vec![dep]);
412        let issues = rule.check(&dep_file);
413        assert!(issues.is_empty());
414    }
415
416    #[test]
417    fn test_deprecated_dep_rust() {
418        let rule = DeprecatedDepRule {
419            ecosystem: Ecosystem::Rust,
420        };
421        let dep_file = make_dep_file(vec![make_dep("serde", "1.0"), make_dep("failure", "0.1")]);
422        let issues = rule.check(&dep_file);
423        assert_eq!(issues.len(), 1);
424        assert!(issues[0].dep_name.as_deref() == Some("failure"));
425    }
426
427    #[test]
428    fn test_duplicated_dep_detected() {
429        let rule = DuplicatedDepRule;
430        let dep_file = make_dep_file(vec![make_dep("serde", "1.0"), make_dep("serde", "1.1")]);
431        let issues = rule.check(&dep_file);
432        assert_eq!(issues.len(), 1);
433    }
434
435    #[test]
436    fn test_check_dep_file_integration() {
437        let deps = vec![
438            make_dep("serde", "1.0"),
439            make_dep("tokio", "*"),
440            make_dep("failure", "0.1"),
441        ];
442        let dep_file = make_dep_file(deps);
443        let issues = check_dep_file(&dep_file);
444        // Should catch wildcard (tokio) and deprecated (failure)
445        assert!(issues.len() >= 2);
446        assert!(issues.iter().any(|i| i.rule_id == "wildcard-version"));
447        assert!(issues.iter().any(|i| i.rule_id == "deprecated-dep"));
448    }
449}