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;
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    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    // Also note non-fixable suggestions as skipped
66    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        // Create backup
102        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        // Write edited TOML
111        fs::write(manifest_path, &edited)
112            .with_context(|| format!("failed to write {}", manifest_path.display()))?;
113
114        // Run cargo update
115        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        // Run cargo check to validate the fix didn't break compilation
145        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
201/// Apply a single suggestion to the TOML document.
202/// Returns a description of what was done on success.
203fn 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
218/// Remove a dependency (StdReplacement: crate replaced by std).
219/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
220fn 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
234/// Rename a dependency (Unmaintained: swap to maintained fork).
235/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
236fn 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
251/// Feature optimization: remove extra dep, add feature to the main dep.
252/// Searches [dependencies], [dev-dependencies], and [build-dependencies].
253/// Pattern format: "main_crate+extra_crate" → "main_crate with \"feature\" feature"
254fn 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    // Parse the feature name from recommended text (e.g. 'reqwest with "json" feature')
264    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    // Find the extra crate in any section and remove it
270    let mut extra_removed_section = None;
271    for section in &sections {
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    // Find the main crate in any section and add the feature
285    for section in &sections {
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
304/// Extract feature name from a recommendation string like 'reqwest with "json" feature'.
305fn extract_feature_name(recommended: &str) -> Option<String> {
306    // Look for text in quotes
307    let start = recommended.find('"')? + 1;
308    let end = recommended[start..].find('"')? + start;
309    Some(recommended[start..end].to_string())
310}
311
312/// Add a feature to an existing dependency entry.
313fn 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            // Simple string version like: reqwest = "0.12"
325            // Convert to table form: reqwest = { version = "0.12", features = ["json"] }
326            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            // Already an inline table like: reqwest = { version = "0.12", features = [...] }
336            if let Some(Value::Array(arr)) = table.get_mut("features") {
337                // Check if feature already exists
338                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            // Full table form
350            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
371/// Print a simple line-by-line diff between old and new content.
372fn 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    // Simple diff: show removed and added lines
377    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")); // other deps untouched
458    }
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")); // other deps untouched
479    }
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        // Should have both blocking and json features
531        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        // File should be unchanged
586        let after = fs::read_to_string(&manifest).unwrap();
587        assert_eq!(after, toml_content);
588
589        // No backup should exist
590        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        // Backup should exist with original content
617        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        // File should be modified
623        let after = fs::read_to_string(&manifest).unwrap();
624        assert!(!after.contains("lazy_static"));
625        assert!(after.contains("serde")); // untouched
626    }
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}