Skip to main content

cargo_bless/
suggestions.rs

1//! Suggestion engine — rule-based recommendations from blessed.rs mappings.
2
3use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::parser::ResolvedDep;
8
9/// A modernization suggestion for a specific dependency.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Suggestion {
12    pub kind: SuggestionKind,
13    pub current: String,
14    pub recommended: String,
15    pub reason: String,
16    pub source: String,
17    pub impact: Impact,
18    pub confidence: Confidence,
19    pub migration_risk: MigrationRisk,
20    pub autofix_safety: AutofixSafety,
21    pub evidence_source: EvidenceSource,
22    /// Workspace member that triggered this suggestion (root-only runs use `None`).
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub package: Option<String>,
25}
26
27impl Suggestion {
28    /// Whether this suggestion can be auto-applied by editing Cargo.toml only.
29    /// Only suggestions explicitly marked as Cargo.toml-only are eligible.
30    pub fn is_auto_fixable(&self) -> bool {
31        matches!(self.autofix_safety, AutofixSafety::CargoTomlOnly)
32    }
33}
34
35/// The type of suggestion.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub enum SuggestionKind {
38    /// Replace with a modern alternative crate.
39    ModernAlternative,
40    /// Enable a built-in feature to drop a separate dependency.
41    FeatureOptimization,
42    /// Replace with a std equivalent (e.g., LazyLock).
43    StdReplacement,
44    /// Consolidate multiple crates doing the same thing.
45    ComboWin,
46    /// Crate is unmaintained — switch to maintained fork/successor.
47    Unmaintained,
48}
49
50/// Impact level of a suggestion.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
52pub enum Impact {
53    High,
54    Medium,
55    Low,
56}
57
58/// Confidence level for a recommendation.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub enum Confidence {
61    High,
62    Medium,
63    Low,
64}
65
66/// Estimated migration risk for applying a recommendation.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub enum MigrationRisk {
69    High,
70    Medium,
71    Low,
72}
73
74/// Whether cargo-bless may safely apply the suggestion itself.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub enum AutofixSafety {
77    /// Safe to apply by changing Cargo.toml only.
78    CargoTomlOnly,
79    /// Requires source review or source edits.
80    ManualOnly,
81}
82
83/// Primary evidence behind a recommendation.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub enum EvidenceSource {
86    BlessedRs,
87    RustSec,
88    StdDocs,
89    CrateDocs,
90    CratesIo,
91    Heuristic,
92}
93
94/// The embedded blessed.rs-based rule database.
95/// Each rule maps a current pattern to a recommended modern alternative.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Rule {
98    pub pattern: String,
99    pub replacement: String,
100    pub kind: SuggestionKind,
101    pub reason: String,
102    pub source: String,
103    pub condition: Option<String>,
104    #[serde(default = "default_confidence")]
105    pub confidence: Confidence,
106    #[serde(default = "default_migration_risk")]
107    pub migration_risk: MigrationRisk,
108    #[serde(default = "default_autofix_safety")]
109    pub autofix_safety: AutofixSafety,
110    #[serde(default = "default_evidence_source")]
111    pub evidence_source: EvidenceSource,
112}
113
114/// Derive impact from suggestion kind.
115fn impact_for(kind: &SuggestionKind) -> Impact {
116    match kind {
117        SuggestionKind::Unmaintained | SuggestionKind::StdReplacement => Impact::High,
118        SuggestionKind::ModernAlternative | SuggestionKind::ComboWin => Impact::Medium,
119        SuggestionKind::FeatureOptimization => Impact::Low,
120    }
121}
122
123fn default_confidence() -> Confidence {
124    Confidence::Medium
125}
126
127fn default_migration_risk() -> MigrationRisk {
128    MigrationRisk::Medium
129}
130
131fn default_autofix_safety() -> AutofixSafety {
132    AutofixSafety::ManualOnly
133}
134
135fn default_evidence_source() -> EvidenceSource {
136    EvidenceSource::Heuristic
137}
138
139/// Load suggestion rules from the embedded database, optionally augmented by a
140/// **fresh** local cache of blessed.rs rules.
141///
142/// **Merge policy:** patterns in `data/suggestions.json` (embedded at compile time)
143/// are authoritative. Cached blessed-derived rules are appended only for patterns
144/// not already defined locally — same policy as `update-suggestions`.
145pub fn load_rules() -> Vec<Rule> {
146    let embedded: Vec<Rule> = {
147        let json = include_str!("../data/suggestions.json");
148        serde_json::from_str(json).expect("embedded suggestions.json should be valid")
149    };
150
151    let Some(cached_blessed) = crate::updater::load_cached_rules() else {
152        return embedded;
153    };
154
155    let embedded_patterns: std::collections::HashSet<String> =
156        embedded.iter().map(|r| r.pattern.clone()).collect();
157
158    let mut merged = embedded;
159    for rule in cached_blessed {
160        if !embedded_patterns.contains(&rule.pattern) {
161            merged.push(rule);
162        }
163    }
164
165    merged
166}
167
168use std::fs;
169/// Analyze resolved dependencies against the rule database.
170///
171/// Matching strategies:
172/// - **Single-crate** rules (pattern has no `+`): fire if a direct dep has that name.
173/// - **Combo** rules (pattern contains `+`): fire if ALL named crates are present
174///   as direct deps.
175use std::path::Path;
176
177pub fn analyze(
178    manifest_path: Option<&Path>,
179    deps: &[ResolvedDep],
180    rules: &[Rule],
181) -> Vec<Suggestion> {
182    analyze_for_package(manifest_path, deps, rules, None)
183}
184
185/// Like [`analyze`], but tags each suggestion with `package` for workspace output.
186pub fn analyze_for_package(
187    manifest_path: Option<&Path>,
188    deps: &[ResolvedDep],
189    rules: &[Rule],
190    package_label: Option<&str>,
191) -> Vec<Suggestion> {
192    let direct_names: HashSet<&str> = deps
193        .iter()
194        .filter(|d| d.is_direct)
195        .map(|d| d.name.as_str())
196        .collect();
197
198    let mut suggestions = Vec::new();
199
200    for rule in rules {
201        let matched = if rule.pattern.contains('+') {
202            // Combo rule: all named crates must be present
203            let all_present = rule
204                .pattern
205                .split('+')
206                .all(|name| direct_names.contains(name.trim()));
207
208            if all_present {
209                // For FeatureOptimization combo rules (like `reqwest+serde_json`),
210                // check if the second crate is actually used directly in the codebase.
211                // If it is, we shouldn't recommend dropping it.
212                if rule.kind == SuggestionKind::FeatureOptimization {
213                    let parts: Vec<&str> = rule.pattern.split('+').collect();
214                    if parts.len() == 2 {
215                        let extra_crate = parts[1].trim();
216                        if is_crate_used_in_source(manifest_path, extra_crate) {
217                            continue;
218                        }
219                    }
220                }
221                true
222            } else {
223                false
224            }
225        } else {
226            // Single-crate rule: exact name match
227            direct_names.contains(rule.pattern.as_str())
228        };
229
230        if matched {
231            suggestions.push(Suggestion {
232                kind: rule.kind.clone(),
233                current: rule.pattern.clone(),
234                recommended: rule.replacement.clone(),
235                reason: rule.reason.clone(),
236                source: rule.source.clone(),
237                impact: impact_for(&rule.kind),
238                confidence: rule.confidence.clone(),
239                migration_risk: rule.migration_risk.clone(),
240                autofix_safety: rule.autofix_safety.clone(),
241                evidence_source: rule.evidence_source.clone(),
242                package: package_label.map(String::from),
243            });
244        }
245    }
246
247    suggestions
248}
249
250/// Recursively scans `.rs` files in the project to determine if the crate is imported or used.
251/// Checks `src`, `tests`, `benches`, and `examples` directories relative to the `manifest_path`.
252fn is_crate_used_in_source(manifest_path: Option<&Path>, crate_name: &str) -> bool {
253    let base_dir = manifest_path
254        .and_then(|p| p.parent())
255        .filter(|p| !p.as_os_str().is_empty())
256        .unwrap_or_else(|| Path::new("."));
257
258    let crate_ident = crate_name.replace('-', "_");
259
260    // We check for some common usage patterns of the crate identifier
261    let patterns = [
262        format!("use {crate_ident}::"),
263        format!("use {crate_ident};"),
264        format!("{crate_ident}::"),
265        format!("{crate_ident}!"),
266    ];
267
268    let dirs_to_check = ["src", "tests", "benches", "examples"];
269
270    for dir_name in dirs_to_check {
271        let dir_path = base_dir.join(dir_name);
272        if !dir_path.exists() || !dir_path.is_dir() {
273            continue;
274        }
275
276        if scan_dir_for_patterns(&dir_path, &patterns) {
277            return true;
278        }
279    }
280
281    false
282}
283
284fn scan_dir_for_patterns(dir: &Path, patterns: &[String]) -> bool {
285    let entries = match fs::read_dir(dir) {
286        Ok(e) => e,
287        Err(_) => return false,
288    };
289
290    for entry in entries.flatten() {
291        let path = entry.path();
292        if path.is_dir() {
293            if scan_dir_for_patterns(&path, patterns) {
294                return true;
295            }
296        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
297            if let Ok(contents) = fs::read_to_string(&path) {
298                for pattern in patterns {
299                    if contents.contains(pattern) {
300                        return true;
301                    }
302                }
303            }
304        }
305    }
306
307    false
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_load_rules() {
316        let rules = load_rules();
317        assert!(
318            rules.len() >= 15,
319            "should load at least 15 rules, got {}",
320            rules.len()
321        );
322
323        // Spot-check a known rule
324        let lazy = rules.iter().find(|r| r.pattern == "lazy_static").unwrap();
325        assert_eq!(lazy.replacement, "std::sync::LazyLock");
326        assert!(matches!(lazy.kind, SuggestionKind::StdReplacement));
327    }
328
329    #[test]
330    fn test_analyze_single_crate_match() {
331        let rules = load_rules();
332        let deps = vec![
333            ResolvedDep {
334                name: "lazy_static".into(),
335                version: "1.5.0".into(),
336                enabled_features: vec![],
337                available_features: vec![],
338                source: Some("registry".into()),
339                repository: None,
340                is_direct: true,
341            },
342            ResolvedDep {
343                name: "serde".into(),
344                version: "1.0.0".into(),
345                enabled_features: vec![],
346                available_features: vec![],
347                source: Some("registry".into()),
348                repository: None,
349                is_direct: true,
350            },
351        ];
352
353        let suggestions = analyze(None, &deps, &rules);
354        assert_eq!(suggestions.len(), 1);
355        assert_eq!(suggestions[0].current, "lazy_static");
356        assert_eq!(suggestions[0].recommended, "std::sync::LazyLock");
357        assert_eq!(suggestions[0].impact, Impact::High);
358    }
359
360    #[test]
361    fn test_analyze_combo_match() {
362        let deps = vec![
363            ResolvedDep {
364                name: "reqwest".into(),
365                version: "0.12.0".into(),
366                enabled_features: vec![],
367                available_features: vec![],
368                source: Some("registry".into()),
369                repository: None,
370                is_direct: true,
371            },
372            ResolvedDep {
373                // Use a crate name that definitely isn't used in this test file
374                name: "some_unused_crate".into(),
375                version: "1.0.0".into(),
376                enabled_features: vec![],
377                available_features: vec![],
378                source: Some("registry".into()),
379                repository: None,
380                is_direct: true,
381            },
382        ];
383
384        // Create a custom rule to avoid triggering the usage grep for real dependencies
385        let custom_rule = Rule {
386            pattern: "reqwest+some_unused_crate".into(),
387            replacement: "reqwest with some feature".into(),
388            kind: SuggestionKind::FeatureOptimization,
389            reason: "".into(),
390            source: "".into(),
391            condition: None,
392            confidence: Confidence::High,
393            migration_risk: MigrationRisk::Low,
394            autofix_safety: AutofixSafety::CargoTomlOnly,
395            evidence_source: EvidenceSource::Heuristic,
396        };
397
398        let suggestions = analyze(None, &deps, &[custom_rule]);
399        assert_eq!(suggestions.len(), 1);
400        assert_eq!(suggestions[0].current, "reqwest+some_unused_crate");
401        assert!(matches!(
402            suggestions[0].kind,
403            SuggestionKind::FeatureOptimization
404        ));
405        assert_eq!(suggestions[0].impact, Impact::Low);
406    }
407
408    #[test]
409    fn test_analyze_combo_partial_no_match() {
410        let rules = load_rules();
411        // Only reqwest present, no serde_json — combo should NOT fire
412        let deps = vec![ResolvedDep {
413            name: "reqwest".into(),
414            version: "0.12.0".into(),
415            enabled_features: vec![],
416            available_features: vec![],
417            source: Some("registry".into()),
418            repository: None,
419            is_direct: true,
420        }];
421
422        let suggestions = analyze(None, &deps, &rules);
423        assert!(
424            suggestions.is_empty(),
425            "combo rule should not fire with only one of the pair"
426        );
427    }
428
429    #[test]
430    fn test_analyze_ignores_transitive() {
431        let rules = load_rules();
432        let deps = vec![ResolvedDep {
433            name: "lazy_static".into(),
434            version: "1.5.0".into(),
435            enabled_features: vec![],
436            available_features: vec![],
437            source: Some("registry".into()),
438            repository: None,
439            is_direct: false, // transitive — should be ignored
440        }];
441
442        let suggestions = analyze(None, &deps, &rules);
443        assert!(
444            suggestions.is_empty(),
445            "transitive deps should not trigger suggestions"
446        );
447    }
448
449    #[test]
450    fn test_analyze_multiple_matches() {
451        let rules = load_rules();
452        let deps = vec![
453            ResolvedDep {
454                name: "lazy_static".into(),
455                version: "1.5.0".into(),
456                enabled_features: vec![],
457                available_features: vec![],
458                source: Some("registry".into()),
459                repository: None,
460                is_direct: true,
461            },
462            ResolvedDep {
463                name: "structopt".into(),
464                version: "0.3.0".into(),
465                enabled_features: vec![],
466                available_features: vec![],
467                source: Some("registry".into()),
468                repository: None,
469                is_direct: true,
470            },
471            ResolvedDep {
472                name: "memmap".into(),
473                version: "0.7.0".into(),
474                enabled_features: vec![],
475                available_features: vec![],
476                source: Some("registry".into()),
477                repository: None,
478                is_direct: true,
479            },
480        ];
481
482        let suggestions = analyze(None, &deps, &rules);
483        assert_eq!(suggestions.len(), 3);
484
485        let names: Vec<&str> = suggestions.iter().map(|s| s.current.as_str()).collect();
486        assert!(names.contains(&"lazy_static"));
487        assert!(names.contains(&"structopt"));
488        assert!(names.contains(&"memmap"));
489    }
490
491    #[test]
492    fn test_analyze_clean_project() {
493        let rules = load_rules();
494        // Modern deps that shouldn't trigger any rules
495        let deps = vec![
496            ResolvedDep {
497                name: "clap".into(),
498                version: "4.5.0".into(),
499                enabled_features: vec!["derive".into()],
500                available_features: vec![],
501                source: Some("registry".into()),
502                repository: None,
503                is_direct: true,
504            },
505            ResolvedDep {
506                name: "serde".into(),
507                version: "1.0.0".into(),
508                enabled_features: vec!["derive".into()],
509                available_features: vec![],
510                source: Some("registry".into()),
511                repository: None,
512                is_direct: true,
513            },
514        ];
515
516        let suggestions = analyze(None, &deps, &rules);
517        assert!(
518            suggestions.is_empty(),
519            "modern deps should not trigger any suggestions"
520        );
521    }
522
523    #[test]
524    fn test_impact_derivation() {
525        assert_eq!(impact_for(&SuggestionKind::Unmaintained), Impact::High);
526        assert_eq!(impact_for(&SuggestionKind::StdReplacement), Impact::High);
527        assert_eq!(
528            impact_for(&SuggestionKind::ModernAlternative),
529            Impact::Medium
530        );
531        assert_eq!(impact_for(&SuggestionKind::ComboWin), Impact::Medium);
532        assert_eq!(
533            impact_for(&SuggestionKind::FeatureOptimization),
534            Impact::Low
535        );
536    }
537}