Skip to main content

cargo_bless/
suggestions.rs

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