use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use anyhow::{Context, Result};
use colored::*;
use toml_edit::{DocumentMut, Item, Value};
use crate::suggestions::{Suggestion, SuggestionKind};
pub struct FixResult {
pub applied: Vec<String>,
pub skipped: Vec<String>,
}
pub fn apply(suggestions: &[Suggestion], manifest_path: &Path, dry_run: bool) -> Result<FixResult> {
let fixable: Vec<&Suggestion> = suggestions.iter().filter(|s| s.is_auto_fixable()).collect();
if fixable.is_empty() {
println!(
"{}",
"ℹ️ No auto-fixable suggestions found. Manual changes recommended above.".dimmed()
);
return Ok(FixResult {
applied: vec![],
skipped: suggestions.iter().map(|s| s.current.clone()).collect(),
});
}
eprintln!(
"{}",
"ℹ️ Autofix: edits Cargo.toml dependency lines only — never Rust source.".dimmed()
);
let original = fs::read_to_string(manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let mut doc: DocumentMut = original
.parse()
.with_context(|| format!("failed to parse {} as TOML", manifest_path.display()))?;
let mut applied = Vec::new();
let mut skipped = Vec::new();
for suggestion in &fixable {
match apply_single(&mut doc, suggestion) {
Ok(desc) => applied.push(desc),
Err(e) => {
skipped.push(format!("{}: {}", suggestion.current, e));
}
}
}
for suggestion in suggestions {
if !suggestion.is_auto_fixable() {
skipped.push(format!(
"{} (requires source code changes)",
suggestion.current
));
}
}
let edited = doc.to_string();
if dry_run {
println!(
"🔍 {}",
"Dry-run: the following changes would be made:".bold()
);
println!();
print_diff(&original, &edited);
if !applied.is_empty() {
println!();
println!("{}", "Changes that would be applied:".bold());
for desc in &applied {
println!(" {} {}", "✓".green(), desc);
}
}
if !skipped.is_empty() {
println!();
println!("{}", "Skipped (manual action needed):".dimmed());
for desc in &skipped {
println!(" {} {}", "–".dimmed(), desc.dimmed());
}
}
} else {
let backup_path = manifest_path.with_extension("toml.bak");
fs::copy(manifest_path, &backup_path)
.with_context(|| format!("failed to create backup at {}", backup_path.display()))?;
println!(
"📋 Backup saved to {}",
backup_path.display().to_string().dimmed()
);
fs::write(manifest_path, &edited)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
run_cargo_validation(
"cargo update",
"📦 Running cargo update...",
"✅ cargo update completed successfully.",
&["update", "--manifest-path"],
manifest_path,
);
run_cargo_validation(
"cargo check",
"🔍 Running cargo check...",
"✅ cargo check passed — project still compiles.",
&["check", "--manifest-path"],
manifest_path,
);
println!();
if !applied.is_empty() {
println!("{}", "Applied fixes:".bold().green());
for desc in &applied {
println!(" {} {}", "✓".green(), desc);
}
}
if !skipped.is_empty() {
println!();
println!("{}", "Skipped (manual action needed):".dimmed());
for desc in &skipped {
println!(" {} {}", "–".dimmed(), desc.dimmed());
}
}
}
Ok(FixResult { applied, skipped })
}
fn run_cargo_validation(
command_name: &str,
start_message: &str,
success_message: &str,
args: &[&str],
manifest_path: &Path,
) {
println!("{}", start_message.dimmed());
let output = Command::new("cargo").args(args).arg(manifest_path).output();
match output {
Ok(output) if output.status.success() => {
println!("{}", success_message.green());
}
Ok(output) => {
println!(
"{}",
format!(
"⚠️ {command_name} exited with {}. Run `{command_name} --manifest-path {}` for details.",
output.status,
manifest_path.display()
)
.yellow()
);
if let Some(summary) = validation_summary(&output) {
println!(" {}", summary.dimmed());
}
}
Err(err) => {
println!(
"{}",
format!("⚠️ Failed to run {command_name}: {err}").yellow()
);
}
}
}
fn validation_summary(output: &Output) -> Option<String> {
let stderr = String::from_utf8_lossy(&output.stderr);
stderr
.lines()
.chain(String::from_utf8_lossy(&output.stdout).lines())
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}
fn apply_single(doc: &mut DocumentMut, suggestion: &Suggestion) -> Result<String> {
match suggestion.kind {
SuggestionKind::StdReplacement => {
apply_remove(doc, &suggestion.current, &suggestion.recommended)
}
SuggestionKind::Unmaintained => {
apply_rename(doc, &suggestion.current, &suggestion.recommended)
}
SuggestionKind::FeatureOptimization => {
apply_feature_opt(doc, &suggestion.current, &suggestion.recommended)
}
_ => anyhow::bail!("not auto-fixable"),
}
}
fn apply_remove(doc: &mut DocumentMut, crate_name: &str, replacement: &str) -> Result<String> {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
if deps.remove(crate_name).is_some() {
return Ok(format!(
"Removed `{}` from [{}] (use {} instead)",
crate_name, section, replacement
));
}
}
}
anyhow::bail!("`{}` not found in any dependency section", crate_name)
}
fn apply_rename(doc: &mut DocumentMut, old_name: &str, new_name: &str) -> Result<String> {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
if let Some(old_item) = deps.remove(old_name) {
deps.insert(new_name, old_item);
return Ok(format!(
"Renamed `{}` → `{}` in [{}]",
old_name, new_name, section
));
}
}
}
anyhow::bail!("`{}` not found in any dependency section", old_name)
}
fn apply_feature_opt(doc: &mut DocumentMut, pattern: &str, recommended: &str) -> Result<String> {
let parts: Vec<&str> = pattern.split('+').collect();
if parts.len() != 2 {
anyhow::bail!("expected pattern format 'crate1+crate2', got '{}'", pattern);
}
let main_crate = parts[0].trim();
let extra_crate = parts[1].trim();
let feature_name = extract_feature_name(recommended)
.ok_or_else(|| anyhow::anyhow!("could not parse feature name from '{}'", recommended))?;
let sections = ["dependencies", "dev-dependencies", "build-dependencies"];
let mut extra_removed_section = None;
for section in §ions {
if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
if deps.remove(extra_crate).is_some() {
extra_removed_section = Some(*section);
break;
}
}
}
if extra_removed_section.is_none() {
anyhow::bail!("`{}` not found in any dependency section", extra_crate);
}
for section in §ions {
if let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_like_mut()) {
if deps.get(main_crate).is_some() {
add_feature_to_dep(deps, main_crate, &feature_name)?;
return Ok(format!(
"Removed `{}` from [{}], enabled `{}` feature on `{}` in [{}]",
extra_crate,
extra_removed_section.unwrap(),
feature_name,
main_crate,
section
));
}
}
}
anyhow::bail!("`{}` not found in any dependency section", main_crate)
}
fn extract_feature_name(recommended: &str) -> Option<String> {
let start = recommended.find('"')? + 1;
let end = recommended[start..].find('"')? + start;
Some(recommended[start..end].to_string())
}
fn add_feature_to_dep(
deps: &mut dyn toml_edit::TableLike,
crate_name: &str,
feature: &str,
) -> Result<()> {
let entry = deps
.get_mut(crate_name)
.ok_or_else(|| anyhow::anyhow!("`{}` not found in [dependencies]", crate_name))?;
match entry {
Item::Value(Value::String(version_str)) => {
let version = version_str.value().clone();
let mut table = toml_edit::InlineTable::new();
table.insert("version", Value::from(version));
let mut features = toml_edit::Array::new();
features.push(feature);
table.insert("features", Value::Array(features));
*entry = Item::Value(Value::InlineTable(table));
}
Item::Value(Value::InlineTable(table)) => {
if let Some(Value::Array(arr)) = table.get_mut("features") {
let has_feature = arr.iter().any(|v| v.as_str() == Some(feature));
if !has_feature {
arr.push(feature);
}
} else {
let mut features = toml_edit::Array::new();
features.push(feature);
table.insert("features", Value::Array(features));
}
}
Item::Table(table) => {
if let Some(features_item) = table.get_mut("features") {
if let Item::Value(Value::Array(arr)) = features_item {
let has_feature = arr.iter().any(|v| v.as_str() == Some(feature));
if !has_feature {
arr.push(feature);
}
}
} else {
let mut features = toml_edit::Array::new();
features.push(feature);
table.insert("features", toml_edit::value(Value::Array(features)));
}
}
_ => {
anyhow::bail!("unexpected dependency format for `{}`", crate_name);
}
}
Ok(())
}
fn print_diff(old: &str, new: &str) {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut shown_header = false;
for line in &old_lines {
if !new_lines.contains(line) {
if !shown_header {
println!("{}", "--- Cargo.toml (original)".dimmed());
println!("{}", "+++ Cargo.toml (modified)".dimmed());
println!();
shown_header = true;
}
println!("{}", format!("- {}", line).red());
}
}
for line in &new_lines {
if !old_lines.contains(line) {
if !shown_header {
println!("{}", "--- Cargo.toml (original)".dimmed());
println!("{}", "+++ Cargo.toml (modified)".dimmed());
println!();
shown_header = true;
}
println!("{}", format!("+ {}", line).green());
}
}
if !shown_header {
println!("{}", " (no changes)".dimmed());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::suggestions::{
AutofixSafety, Confidence, EvidenceSource, Impact, MigrationRisk, SuggestionKind,
};
use tempfile::TempDir;
fn make_suggestion(kind: SuggestionKind, current: &str, recommended: &str) -> Suggestion {
Suggestion {
kind: kind.clone(),
current: current.into(),
recommended: recommended.into(),
reason: "test reason".into(),
source: "test".into(),
impact: match kind {
SuggestionKind::Unmaintained | SuggestionKind::StdReplacement => Impact::High,
SuggestionKind::ModernAlternative | SuggestionKind::ComboWin => Impact::Medium,
SuggestionKind::FeatureOptimization => Impact::Low,
},
confidence: Confidence::High,
migration_risk: MigrationRisk::Low,
autofix_safety: match kind {
SuggestionKind::ModernAlternative | SuggestionKind::ComboWin => {
AutofixSafety::ManualOnly
}
_ => AutofixSafety::CargoTomlOnly,
},
evidence_source: EvidenceSource::Heuristic,
package: None,
}
}
#[test]
fn test_remove_dep() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
lazy_static = "1.5"
serde = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
assert!(result.contains("Removed `lazy_static`"));
let edited = doc.to_string();
assert!(!edited.contains("lazy_static"));
assert!(edited.contains("serde")); }
#[test]
fn test_rename_dep() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
memmap = "0.7"
serde = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_rename(&mut doc, "memmap", "memmap2").unwrap();
assert!(result.contains("Renamed `memmap` → `memmap2`"));
let edited = doc.to_string();
assert!(!edited.contains("memmap ="));
assert!(edited.contains("memmap2"));
assert!(edited.contains("serde")); }
#[test]
fn test_feature_opt_simple_version() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
reqwest = "0.12"
serde_json = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_feature_opt(
&mut doc,
"reqwest+serde_json",
r#"reqwest with "json" feature"#,
)
.unwrap();
assert!(result.contains("Removed `serde_json`"));
assert!(result.contains("enabled `json` feature on `reqwest`"));
let edited = doc.to_string();
assert!(!edited.contains("serde_json"));
assert!(edited.contains("json"));
assert!(edited.contains("reqwest"));
}
#[test]
fn test_feature_opt_inline_table() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
reqwest = { version = "0.12", features = ["blocking"] }
serde_json = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_feature_opt(
&mut doc,
"reqwest+serde_json",
r#"reqwest with "json" feature"#,
)
.unwrap();
assert!(result.contains("Removed `serde_json`"));
let edited = doc.to_string();
assert!(!edited.contains("serde_json"));
assert!(edited.contains("blocking"));
assert!(edited.contains("json"));
}
#[test]
fn test_extract_feature_name() {
assert_eq!(
extract_feature_name(r#"reqwest with "json" feature"#),
Some("json".into())
);
assert_eq!(
extract_feature_name(r#"tokio with "full" feature"#),
Some("full".into())
);
assert_eq!(extract_feature_name("no quotes here"), None);
}
#[test]
fn test_remove_nonexistent_dep() {
let toml = r#"
[package]
name = "test-project"
[dependencies]
serde = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_remove(&mut doc, "nonexistent", "something");
assert!(result.is_err());
}
#[test]
fn test_dry_run_does_not_write() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("Cargo.toml");
let toml_content = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
lazy_static = "1.5"
"#;
fs::write(&manifest, toml_content).unwrap();
let suggestions = vec![make_suggestion(
SuggestionKind::StdReplacement,
"lazy_static",
"std::sync::LazyLock",
)];
let result = apply(&suggestions, &manifest, true).unwrap();
assert_eq!(result.applied.len(), 1);
let after = fs::read_to_string(&manifest).unwrap();
assert_eq!(after, toml_content);
assert!(!tmp.path().join("Cargo.toml.bak").exists());
}
#[test]
fn test_full_apply_creates_backup() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("Cargo.toml");
let toml_content = r#"[package]
name = "test-project"
version = "0.1.0"
[dependencies]
lazy_static = "1.5"
serde = "1.0"
"#;
fs::write(&manifest, toml_content).unwrap();
let suggestions = vec![make_suggestion(
SuggestionKind::StdReplacement,
"lazy_static",
"std::sync::LazyLock",
)];
let result = apply(&suggestions, &manifest, false).unwrap();
assert_eq!(result.applied.len(), 1);
let backup = tmp.path().join("Cargo.toml.bak");
assert!(backup.exists());
let backup_content = fs::read_to_string(&backup).unwrap();
assert_eq!(backup_content, toml_content);
let after = fs::read_to_string(&manifest).unwrap();
assert!(!after.contains("lazy_static"));
assert!(after.contains("serde")); }
#[test]
fn test_no_fixable_suggestions() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("Cargo.toml");
fs::write(&manifest, "[package]\nname = \"test\"\n[dependencies]\n").unwrap();
let suggestions = vec![make_suggestion(
SuggestionKind::ModernAlternative,
"structopt",
"clap v4",
)];
let result = apply(&suggestions, &manifest, true).unwrap();
assert!(result.applied.is_empty());
assert_eq!(result.skipped.len(), 1);
}
#[test]
fn test_remove_from_dev_dependencies() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
serde = "1.0"
[dev-dependencies]
lazy_static = "1.5"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
assert!(result.contains("Removed `lazy_static`"));
assert!(result.contains("[dev-dependencies]"));
let edited = doc.to_string();
assert!(!edited.contains("lazy_static"));
assert!(edited.contains("serde"));
}
#[test]
fn test_remove_from_build_dependencies() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
serde = "1.0"
[build-dependencies]
lazy_static = "1.5"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_remove(&mut doc, "lazy_static", "std::sync::LazyLock").unwrap();
assert!(result.contains("[build-dependencies]"));
let edited = doc.to_string();
assert!(!edited.contains("lazy_static"));
}
#[test]
fn test_rename_from_dev_dependencies() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dev-dependencies]
memmap = "0.7"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_rename(&mut doc, "memmap", "memmap2").unwrap();
assert!(result.contains("Renamed `memmap` → `memmap2`"));
assert!(result.contains("[dev-dependencies]"));
let edited = doc.to_string();
assert!(!edited.contains("memmap ="));
assert!(edited.contains("memmap2"));
}
#[test]
fn test_feature_opt_across_sections() {
let toml = r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
reqwest = "0.12"
[dev-dependencies]
serde_json = "1.0"
"#;
let mut doc: DocumentMut = toml.parse().unwrap();
let result = apply_feature_opt(
&mut doc,
"reqwest+serde_json",
r#"reqwest with "json" feature"#,
)
.unwrap();
assert!(result.contains("Removed `serde_json`"));
assert!(result.contains("[dev-dependencies]"));
assert!(result.contains("enabled `json` feature on `reqwest` in [dependencies]"));
let edited = doc.to_string();
assert!(!edited.contains("serde_json"));
assert!(edited.contains("json"));
}
}