1use std::fs;
11use std::path::Path;
12use std::process::{Command, Output};
13
14use anyhow::{Context, Result};
15use colored::*;
16use toml_edit::{DocumentMut, Item, Value};
17
18use crate::suggestions::{Suggestion, SuggestionKind};
19
20pub struct FixResult {
22 pub applied: Vec<String>,
23 pub skipped: Vec<String>,
24}
25
26pub fn apply(suggestions: &[Suggestion], manifest_path: &Path, dry_run: bool) -> Result<FixResult> {
33 let fixable: Vec<&Suggestion> = suggestions.iter().filter(|s| s.is_auto_fixable()).collect();
34
35 if fixable.is_empty() {
36 println!(
37 "{}",
38 "ℹ️ No auto-fixable suggestions found. Manual changes recommended above.".dimmed()
39 );
40 return Ok(FixResult {
41 applied: vec![],
42 skipped: suggestions.iter().map(|s| s.current.clone()).collect(),
43 });
44 }
45
46 eprintln!(
47 "{}",
48 "ℹ️ Autofix: edits Cargo.toml dependency lines only — never Rust source.".dimmed()
49 );
50
51 let original = fs::read_to_string(manifest_path)
52 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
53
54 let mut doc: DocumentMut = original
55 .parse()
56 .with_context(|| format!("failed to parse {} as TOML", manifest_path.display()))?;
57
58 let mut applied = Vec::new();
59 let mut skipped = Vec::new();
60
61 for suggestion in &fixable {
62 match apply_single(&mut doc, suggestion) {
63 Ok(desc) => applied.push(desc),
64 Err(e) => {
65 skipped.push(format!("{}: {}", suggestion.current, e));
66 }
67 }
68 }
69
70 for suggestion in suggestions {
72 if !suggestion.is_auto_fixable() {
73 skipped.push(format!(
74 "{} (requires source code changes)",
75 suggestion.current
76 ));
77 }
78 }
79
80 let edited = doc.to_string();
81
82 if dry_run {
83 println!(
84 "🔍 {}",
85 "Dry-run: the following changes would be made:".bold()
86 );
87 println!();
88 print_diff(&original, &edited);
89
90 if !applied.is_empty() {
91 println!();
92 println!("{}", "Changes that would be applied:".bold());
93 for desc in &applied {
94 println!(" {} {}", "✓".green(), desc);
95 }
96 }
97
98 if !skipped.is_empty() {
99 println!();
100 println!("{}", "Skipped (manual action needed):".dimmed());
101 for desc in &skipped {
102 println!(" {} {}", "–".dimmed(), desc.dimmed());
103 }
104 }
105 } else {
106 let backup_path = manifest_path.with_extension("toml.bak");
108 fs::copy(manifest_path, &backup_path)
109 .with_context(|| format!("failed to create backup at {}", backup_path.display()))?;
110 println!(
111 "📋 Backup saved to {}",
112 backup_path.display().to_string().dimmed()
113 );
114
115 fs::write(manifest_path, &edited)
117 .with_context(|| format!("failed to write {}", manifest_path.display()))?;
118
119 run_cargo_validation(
120 "cargo update",
121 "📦 Running cargo update...",
122 "✅ cargo update completed successfully.",
123 &["update", "--manifest-path"],
124 manifest_path,
125 );
126
127 run_cargo_validation(
128 "cargo check",
129 "🔍 Running cargo check...",
130 "✅ cargo check passed — project still compiles.",
131 &["check", "--manifest-path"],
132 manifest_path,
133 );
134
135 println!();
136 if !applied.is_empty() {
137 println!("{}", "Applied fixes:".bold().green());
138 for desc in &applied {
139 println!(" {} {}", "✓".green(), desc);
140 }
141 }
142
143 if !skipped.is_empty() {
144 println!();
145 println!("{}", "Skipped (manual action needed):".dimmed());
146 for desc in &skipped {
147 println!(" {} {}", "–".dimmed(), desc.dimmed());
148 }
149 }
150 }
151
152 Ok(FixResult { applied, skipped })
153}
154
155fn run_cargo_validation(
156 command_name: &str,
157 start_message: &str,
158 success_message: &str,
159 args: &[&str],
160 manifest_path: &Path,
161) {
162 println!("{}", start_message.dimmed());
163 let output = Command::new("cargo").args(args).arg(manifest_path).output();
164
165 match output {
166 Ok(output) if output.status.success() => {
167 println!("{}", success_message.green());
168 }
169 Ok(output) => {
170 println!(
171 "{}",
172 format!(
173 "⚠️ {command_name} exited with {}. Run `{command_name} --manifest-path {}` for details.",
174 output.status,
175 manifest_path.display()
176 )
177 .yellow()
178 );
179 if let Some(summary) = validation_summary(&output) {
180 println!(" {}", summary.dimmed());
181 }
182 }
183 Err(err) => {
184 println!(
185 "{}",
186 format!("⚠️ Failed to run {command_name}: {err}").yellow()
187 );
188 }
189 }
190}
191
192fn validation_summary(output: &Output) -> Option<String> {
193 let stderr = String::from_utf8_lossy(&output.stderr);
194 stderr
195 .lines()
196 .chain(String::from_utf8_lossy(&output.stdout).lines())
197 .map(str::trim)
198 .find(|line| !line.is_empty())
199 .map(str::to_string)
200}
201
202fn apply_single(doc: &mut DocumentMut, suggestion: &Suggestion) -> Result<String> {
205 match suggestion.kind {
206 SuggestionKind::StdReplacement => {
207 apply_remove(doc, &suggestion.current, &suggestion.recommended)
208 }
209 SuggestionKind::Unmaintained => {
210 apply_rename(doc, &suggestion.current, &suggestion.recommended)
211 }
212 SuggestionKind::FeatureOptimization => {
213 apply_feature_opt(doc, &suggestion.current, &suggestion.recommended)
214 }
215 _ => anyhow::bail!("not auto-fixable"),
216 }
217}
218
219fn apply_remove(doc: &mut DocumentMut, crate_name: &str, replacement: &str) -> Result<String> {
222 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
223 if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
224 if deps.remove(crate_name).is_some() {
225 return Ok(format!(
226 "Removed `{}` from [{}] (use {} instead)",
227 crate_name, section, replacement
228 ));
229 }
230 }
231 }
232 anyhow::bail!("`{}` not found in any dependency section", crate_name)
233}
234
235fn apply_rename(doc: &mut DocumentMut, old_name: &str, new_name: &str) -> Result<String> {
238 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
239 if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
240 if let Some(old_item) = deps.remove(old_name) {
241 deps.insert(new_name, old_item);
242 return Ok(format!(
243 "Renamed `{}` → `{}` in [{}]",
244 old_name, new_name, section
245 ));
246 }
247 }
248 }
249 anyhow::bail!("`{}` not found in any dependency section", old_name)
250}
251
252fn apply_feature_opt(doc: &mut DocumentMut, pattern: &str, recommended: &str) -> Result<String> {
256 let parts: Vec<&str> = pattern.split('+').collect();
257 if parts.len() != 2 {
258 anyhow::bail!("expected pattern format 'crate1+crate2', got '{}'", pattern);
259 }
260
261 let main_crate = parts[0].trim();
262 let extra_crate = parts[1].trim();
263
264 let feature_name = extract_feature_name(recommended)
266 .ok_or_else(|| anyhow::anyhow!("could not parse feature name from '{}'", recommended))?;
267
268 let sections = ["dependencies", "dev-dependencies", "build-dependencies"];
269
270 let mut extra_removed_section = None;
272 for section in §ions {
273 if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
274 if deps.remove(extra_crate).is_some() {
275 extra_removed_section = Some(*section);
276 break;
277 }
278 }
279 }
280
281 if extra_removed_section.is_none() {
282 anyhow::bail!("`{}` not found in any dependency section", extra_crate);
283 }
284
285 for section in §ions {
287 if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
288 if deps.get(main_crate).is_some() {
289 add_feature_to_dep(deps, main_crate, &feature_name)?;
290 return Ok(format!(
291 "Removed `{}` from [{}], enabled `{}` feature on `{}` in [{}]",
292 extra_crate,
293 extra_removed_section.unwrap(),
294 feature_name,
295 main_crate,
296 section
297 ));
298 }
299 }
300 }
301
302 anyhow::bail!("`{}` not found in any dependency section", main_crate)
303}
304
305fn extract_feature_name(recommended: &str) -> Option<String> {
307 let start = recommended.find('"')? + 1;
309 let end = recommended[start..].find('"')? + start;
310 Some(recommended[start..end].to_string())
311}
312
313fn add_feature_to_dep(
315 deps: &mut dyn toml_edit::TableLike,
316 crate_name: &str,
317 feature: &str,
318) -> Result<()> {
319 let entry = deps
320 .get_mut(crate_name)
321 .ok_or_else(|| anyhow::anyhow!("`{}` not found in [dependencies]", crate_name))?;
322
323 match entry {
324 Item::Value(Value::String(version_str)) => {
325 let version = version_str.value().clone();
328 let mut table = toml_edit::InlineTable::new();
329 table.insert("version", Value::from(version));
330 let mut features = toml_edit::Array::new();
331 features.push(feature);
332 table.insert("features", Value::Array(features));
333 *entry = Item::Value(Value::InlineTable(table));
334 }
335 Item::Value(Value::InlineTable(table)) => {
336 if let Some(Value::Array(arr)) = table.get_mut("features") {
338 let has_feature = arr.iter().any(|v| v.as_str() == Some(feature));
340 if !has_feature {
341 arr.push(feature);
342 }
343 } else {
344 let mut features = toml_edit::Array::new();
345 features.push(feature);
346 table.insert("features", Value::Array(features));
347 }
348 }
349 Item::Table(table) => {
350 if let Some(features_item) = table.get_mut("features") {
352 if let Item::Value(Value::Array(arr)) = features_item {
353 let has_feature = arr.iter().any(|v| v.as_str() == Some(feature));
354 if !has_feature {
355 arr.push(feature);
356 }
357 }
358 } else {
359 let mut features = toml_edit::Array::new();
360 features.push(feature);
361 table.insert("features", toml_edit::value(Value::Array(features)));
362 }
363 }
364 _ => {
365 anyhow::bail!("unexpected dependency format for `{}`", crate_name);
366 }
367 }
368
369 Ok(())
370}
371
372fn print_diff(old: &str, new: &str) {
374 let old_lines: Vec<&str> = old.lines().collect();
375 let new_lines: Vec<&str> = new.lines().collect();
376
377 let mut shown_header = false;
379
380 for line in &old_lines {
381 if !new_lines.contains(line) {
382 if !shown_header {
383 println!("{}", "--- Cargo.toml (original)".dimmed());
384 println!("{}", "+++ Cargo.toml (modified)".dimmed());
385 println!();
386 shown_header = true;
387 }
388 println!("{}", format!("- {}", line).red());
389 }
390 }
391
392 for line in &new_lines {
393 if !old_lines.contains(line) {
394 if !shown_header {
395 println!("{}", "--- Cargo.toml (original)".dimmed());
396 println!("{}", "+++ Cargo.toml (modified)".dimmed());
397 println!();
398 shown_header = true;
399 }
400 println!("{}", format!("+ {}", line).green());
401 }
402 }
403
404 if !shown_header {
405 println!("{}", " (no changes)".dimmed());
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::suggestions::{
413 AutofixSafety, Confidence, EvidenceSource, Impact, MigrationRisk, SuggestionKind,
414 };
415 use tempfile::TempDir;
416
417 fn make_suggestion(kind: SuggestionKind, current: &str, recommended: &str) -> Suggestion {
418 Suggestion {
419 kind: kind.clone(),
420 current: current.into(),
421 recommended: recommended.into(),
422 reason: "test reason".into(),
423 source: "test".into(),
424 impact: match kind {
425 SuggestionKind::Unmaintained | SuggestionKind::StdReplacement => Impact::High,
426 SuggestionKind::ModernAlternative | SuggestionKind::ComboWin => Impact::Medium,
427 SuggestionKind::FeatureOptimization => Impact::Low,
428 },
429 confidence: Confidence::High,
430 migration_risk: MigrationRisk::Low,
431 autofix_safety: match kind {
432 SuggestionKind::ModernAlternative | SuggestionKind::ComboWin => {
433 AutofixSafety::ManualOnly
434 }
435 _ => AutofixSafety::CargoTomlOnly,
436 },
437 evidence_source: EvidenceSource::Heuristic,
438 package: None,
439 }
440 }
441
442 #[test]
443 fn test_remove_dep() {
444 let toml = r#"
445[package]
446name = "test-project"
447version = "0.1.0"
448
449[dependencies]
450lazy_static = "1.5"
451serde = "1.0"
452"#;
453 let mut doc: DocumentMut = toml.parse().unwrap();
454 let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
455
456 assert!(result.contains("Removed `lazy_static`"));
457 let edited = doc.to_string();
458 assert!(!edited.contains("lazy_static"));
459 assert!(edited.contains("serde")); }
461
462 #[test]
463 fn test_rename_dep() {
464 let toml = r#"
465[package]
466name = "test-project"
467version = "0.1.0"
468
469[dependencies]
470memmap = "0.7"
471serde = "1.0"
472"#;
473 let mut doc: DocumentMut = toml.parse().unwrap();
474 let result = apply_rename(&mut doc, "memmap", "memmap2").unwrap();
475
476 assert!(result.contains("Renamed `memmap` → `memmap2`"));
477 let edited = doc.to_string();
478 assert!(!edited.contains("memmap ="));
479 assert!(edited.contains("memmap2"));
480 assert!(edited.contains("serde")); }
482
483 #[test]
484 fn test_feature_opt_simple_version() {
485 let toml = r#"
486[package]
487name = "test-project"
488version = "0.1.0"
489
490[dependencies]
491reqwest = "0.12"
492serde_json = "1.0"
493"#;
494 let mut doc: DocumentMut = toml.parse().unwrap();
495 let result = apply_feature_opt(
496 &mut doc,
497 "reqwest+serde_json",
498 r#"reqwest with "json" feature"#,
499 )
500 .unwrap();
501
502 assert!(result.contains("Removed `serde_json`"));
503 assert!(result.contains("enabled `json` feature on `reqwest`"));
504 let edited = doc.to_string();
505 assert!(!edited.contains("serde_json"));
506 assert!(edited.contains("json"));
507 assert!(edited.contains("reqwest"));
508 }
509
510 #[test]
511 fn test_feature_opt_inline_table() {
512 let toml = r#"
513[package]
514name = "test-project"
515version = "0.1.0"
516
517[dependencies]
518reqwest = { version = "0.12", features = ["blocking"] }
519serde_json = "1.0"
520"#;
521 let mut doc: DocumentMut = toml.parse().unwrap();
522 let result = apply_feature_opt(
523 &mut doc,
524 "reqwest+serde_json",
525 r#"reqwest with "json" feature"#,
526 )
527 .unwrap();
528
529 assert!(result.contains("Removed `serde_json`"));
530 let edited = doc.to_string();
531 assert!(!edited.contains("serde_json"));
532 assert!(edited.contains("blocking"));
534 assert!(edited.contains("json"));
535 }
536
537 #[test]
538 fn test_extract_feature_name() {
539 assert_eq!(
540 extract_feature_name(r#"reqwest with "json" feature"#),
541 Some("json".into())
542 );
543 assert_eq!(
544 extract_feature_name(r#"tokio with "full" feature"#),
545 Some("full".into())
546 );
547 assert_eq!(extract_feature_name("no quotes here"), None);
548 }
549
550 #[test]
551 fn test_remove_nonexistent_dep() {
552 let toml = r#"
553[package]
554name = "test-project"
555
556[dependencies]
557serde = "1.0"
558"#;
559 let mut doc: DocumentMut = toml.parse().unwrap();
560 let result = apply_remove(&mut doc, "nonexistent", "something");
561 assert!(result.is_err());
562 }
563
564 #[test]
565 fn test_dry_run_does_not_write() {
566 let tmp = TempDir::new().unwrap();
567 let manifest = tmp.path().join("Cargo.toml");
568 let toml_content = r#"
569[package]
570name = "test-project"
571version = "0.1.0"
572
573[dependencies]
574lazy_static = "1.5"
575"#;
576 fs::write(&manifest, toml_content).unwrap();
577
578 let suggestions = vec![make_suggestion(
579 SuggestionKind::StdReplacement,
580 "lazy_static",
581 "std::sync::LazyLock",
582 )];
583
584 let result = apply(&suggestions, &manifest, true).unwrap();
585 assert_eq!(result.applied.len(), 1);
586
587 let after = fs::read_to_string(&manifest).unwrap();
589 assert_eq!(after, toml_content);
590
591 assert!(!tmp.path().join("Cargo.toml.bak").exists());
593 }
594
595 #[test]
596 fn test_full_apply_creates_backup() {
597 let tmp = TempDir::new().unwrap();
598 let manifest = tmp.path().join("Cargo.toml");
599 let toml_content = r#"[package]
600name = "test-project"
601version = "0.1.0"
602
603[dependencies]
604lazy_static = "1.5"
605serde = "1.0"
606"#;
607 fs::write(&manifest, toml_content).unwrap();
608
609 let suggestions = vec![make_suggestion(
610 SuggestionKind::StdReplacement,
611 "lazy_static",
612 "std::sync::LazyLock",
613 )];
614
615 let result = apply(&suggestions, &manifest, false).unwrap();
616 assert_eq!(result.applied.len(), 1);
617
618 let backup = tmp.path().join("Cargo.toml.bak");
620 assert!(backup.exists());
621 let backup_content = fs::read_to_string(&backup).unwrap();
622 assert_eq!(backup_content, toml_content);
623
624 let after = fs::read_to_string(&manifest).unwrap();
626 assert!(!after.contains("lazy_static"));
627 assert!(after.contains("serde")); }
629
630 #[test]
631 fn test_no_fixable_suggestions() {
632 let tmp = TempDir::new().unwrap();
633 let manifest = tmp.path().join("Cargo.toml");
634 fs::write(&manifest, "[package]\nname = \"test\"\n[dependencies]\n").unwrap();
635
636 let suggestions = vec![make_suggestion(
637 SuggestionKind::ModernAlternative,
638 "structopt",
639 "clap v4",
640 )];
641
642 let result = apply(&suggestions, &manifest, true).unwrap();
643 assert!(result.applied.is_empty());
644 assert_eq!(result.skipped.len(), 1);
645 }
646
647 #[test]
648 fn test_remove_from_dev_dependencies() {
649 let toml = r#"
650[package]
651name = "test-project"
652version = "0.1.0"
653
654[dependencies]
655serde = "1.0"
656
657[dev-dependencies]
658lazy_static = "1.5"
659"#;
660 let mut doc: DocumentMut = toml.parse().unwrap();
661 let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
662
663 assert!(result.contains("Removed `lazy_static`"));
664 assert!(result.contains("[dev-dependencies]"));
665 let edited = doc.to_string();
666 assert!(!edited.contains("lazy_static"));
667 assert!(edited.contains("serde"));
668 }
669
670 #[test]
671 fn test_remove_from_build_dependencies() {
672 let toml = r#"
673[package]
674name = "test-project"
675version = "0.1.0"
676
677[dependencies]
678serde = "1.0"
679
680[build-dependencies]
681lazy_static = "1.5"
682"#;
683 let mut doc: DocumentMut = toml.parse().unwrap();
684 let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
685
686 assert!(result.contains("[build-dependencies]"));
687 let edited = doc.to_string();
688 assert!(!edited.contains("lazy_static"));
689 }
690
691 #[test]
692 fn test_rename_from_dev_dependencies() {
693 let toml = r#"
694[package]
695name = "test-project"
696version = "0.1.0"
697
698[dev-dependencies]
699memmap = "0.7"
700"#;
701 let mut doc: DocumentMut = toml.parse().unwrap();
702 let result = apply_rename(&mut doc, "memmap", "memmap2").unwrap();
703
704 assert!(result.contains("Renamed `memmap` → `memmap2`"));
705 assert!(result.contains("[dev-dependencies]"));
706 let edited = doc.to_string();
707 assert!(!edited.contains("memmap ="));
708 assert!(edited.contains("memmap2"));
709 }
710
711 #[test]
712 fn test_feature_opt_across_sections() {
713 let toml = r#"
714[package]
715name = "test-project"
716version = "0.1.0"
717
718[dependencies]
719reqwest = "0.12"
720
721[dev-dependencies]
722serde_json = "1.0"
723"#;
724 let mut doc: DocumentMut = toml.parse().unwrap();
725 let result = apply_feature_opt(
726 &mut doc,
727 "reqwest+serde_json",
728 r#"reqwest with "json" feature"#,
729 )
730 .unwrap();
731
732 assert!(result.contains("Removed `serde_json`"));
733 assert!(result.contains("[dev-dependencies]"));
734 assert!(result.contains("enabled `json` feature on `reqwest` in [dependencies]"));
735 let edited = doc.to_string();
736 assert!(!edited.contains("serde_json"));
737 assert!(edited.contains("json"));
738 }
739}