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