1use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7
8use crate::parser::ResolvedDep;
9
10#[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 pub fn is_auto_fixable(&self) -> bool {
29 matches!(self.autofix_safety, AutofixSafety::CargoTomlOnly)
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub enum SuggestionKind {
36 ModernAlternative,
38 FeatureOptimization,
40 StdReplacement,
42 ComboWin,
44 Unmaintained,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub enum Impact {
51 High,
52 Medium,
53 Low,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub enum Confidence {
59 High,
60 Medium,
61 Low,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub enum MigrationRisk {
67 High,
68 Medium,
69 Low,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub enum AutofixSafety {
75 CargoTomlOnly,
77 ManualOnly,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub enum EvidenceSource {
84 BlessedRs,
85 RustSec,
86 StdDocs,
87 CrateDocs,
88 CratesIo,
89 Heuristic,
90}
91
92#[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
112fn 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
137pub 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 let cached = crate::updater::load_cached_rules();
151
152 match cached {
153 Some(mut blessed_rules) => {
154 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;
171use 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 let all_present = rule
196 .pattern
197 .split('+')
198 .all(|name| direct_names.contains(name.trim()));
199
200 if all_present {
201 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 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
241fn 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 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 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 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 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 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, }];
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 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}