Skip to main content

cargo_bless/
fix.rs

1//! Fix layer — applies auto-fixable suggestions by editing Cargo.toml
2//! using `toml_edit` to preserve comments and formatting.
3//!
4//! Safety guardrails:
5//! - `.bak` backup before any writes  
6//! - `--dry-run` previews the diff without touching files
7//! - Only direct dependency edits (never transitive)
8//! - Only auto-fixable suggestion types (StdReplacement, Unmaintained, FeatureOptimization)
9
10use 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
20/// Result summary of a fix operation.
21pub struct FixResult {
22    pub applied: Vec<String>,
23    pub skipped: Vec<String>,
24}
25
26/// Apply auto-fixable suggestions to the Cargo.toml at `manifest_path`.
27///
28/// - Only processes suggestions where `is_auto_fixable()` is true.
29/// - Creates a `.bak` backup before any edits.
30/// - Uses `toml_edit` to preserve comments and formatting.
31/// - If `dry_run` is true, prints the diff but writes nothing.
32pub 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    // Also note non-fixable suggestions as skipped
71    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        // Create backup
107        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        // Write edited TOML
116        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
202/// Apply a single suggestion to the TOML document.
203/// Returns a description of what was done on success.
204fn 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
219/// Remove a dependency (StdReplacement: crate replaced by std).
220/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
221fn 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
235/// Rename a dependency (Unmaintained: swap to maintained fork).
236/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
237fn 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
252/// Feature optimization: remove extra dep, add feature to the main dep.
253/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
254/// Pattern format: "main_crate+extra_crate" → "main_crate with \"feature\" feature"
255fn 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    // Parse the feature name from recommended text (e.g. 'reqwest with "json" feature')
265    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    // Find the extra crate in any section and remove it
271    let mut extra_removed_section = None;
272    for section in &sections {
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    // Find the main crate in any section and add the feature
286    for section in &sections {
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
305/// Extract feature name from a recommendation string like 'reqwest with "json" feature'.
306fn extract_feature_name(recommended: &str) -> Option<String> {
307    // Look for text in quotes
308    let start = recommended.find('"')? + 1;
309    let end = recommended[start..].find('"')? + start;
310    Some(recommended[start..end].to_string())
311}
312
313/// Add a feature to an existing dependency entry.
314fn 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            // Simple string version like: reqwest = "0.12"
326            // Convert to table form: reqwest = { version = "0.12", features = ["json"] }
327            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            // Already an inline table like: reqwest = { version = "0.12", features = [...] }
337            if let Some(Value::Array(arr)) = table.get_mut("features") {
338                // Check if feature already exists
339                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            // Full table form
351            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
372/// Print a simple line-by-line diff between old and new content.
373fn 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    // Simple diff: show removed and added lines
378    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")); // other deps untouched
460    }
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")); // other deps untouched
481    }
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        // Should have both blocking and json features
533        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        // File should be unchanged
588        let after = fs::read_to_string(&manifest).unwrap();
589        assert_eq!(after, toml_content);
590
591        // No backup should exist
592        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        // Backup should exist with original content
619        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        // File should be modified
625        let after = fs::read_to_string(&manifest).unwrap();
626        assert!(!after.contains("lazy_static"));
627        assert!(after.contains("serde")); // untouched
628    }
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}