1use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::parser::ResolvedDep;
8
9#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub package: Option<String>,
25}
26
27impl Suggestion {
28 pub fn is_auto_fixable(&self) -> bool {
31 matches!(self.autofix_safety, AutofixSafety::CargoTomlOnly)
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub enum SuggestionKind {
38 ModernAlternative,
40 FeatureOptimization,
42 StdReplacement,
44 ComboWin,
46 Unmaintained,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
52pub enum Impact {
53 High,
54 Medium,
55 Low,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub enum Confidence {
61 High,
62 Medium,
63 Low,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub enum MigrationRisk {
69 High,
70 Medium,
71 Low,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub enum AutofixSafety {
77 CargoTomlOnly,
79 ManualOnly,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub enum EvidenceSource {
86 BlessedRs,
87 RustSec,
88 StdDocs,
89 CrateDocs,
90 CratesIo,
91 Heuristic,
92}
93
94#[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
114fn 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
139pub 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;
169use 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
185pub 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 let all_present = rule
204 .pattern
205 .split('+')
206 .all(|name| direct_names.contains(name.trim()));
207
208 if all_present {
209 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 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
250fn 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 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 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 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 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 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, }];
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 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}