use std::fs;
use std::path::Path;
use std::process::Command;
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(),
});
}
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()))?;
println!("{}", "📦 Running cargo update...".dimmed());
let status = Command::new("cargo")
.arg("update")
.current_dir(
manifest_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)
.status();
match status {
Ok(s) if s.success() => {
println!("{}", "✅ cargo update completed successfully.".green());
}
Ok(s) => {
println!(
"{}",
format!("⚠️ cargo update exited with: {}", s).yellow()
);
}
Err(e) => {
println!(
"{}",
format!("⚠️ Failed to run cargo update: {}", e).yellow()
);
}
}
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 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> {
let deps = doc
.get_mut("dependencies")
.and_then(|d| d.as_table_like_mut())
.ok_or_else(|| anyhow::anyhow!("no [dependencies] table found"))?;
if deps.remove(crate_name).is_some() {
Ok(format!(
"Removed `{}` (use {} instead)",
crate_name, replacement
))
} else {
anyhow::bail!("`{}` not found in [dependencies]", crate_name)
}
}
fn apply_rename(doc: &mut DocumentMut, old_name: &str, new_name: &str) -> Result<String> {
let deps = doc
.get_mut("dependencies")
.and_then(|d| d.as_table_like_mut())
.ok_or_else(|| anyhow::anyhow!("no [dependencies] table found"))?;
let old_item = deps
.remove(old_name)
.ok_or_else(|| anyhow::anyhow!("`{}` not found in [dependencies]", old_name))?;
deps.insert(new_name, old_item);
Ok(format!("Renamed `{}` → `{}`", old_name, new_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 deps = doc
.get_mut("dependencies")
.and_then(|d| d.as_table_like_mut())
.ok_or_else(|| anyhow::anyhow!("no [dependencies] table found"))?;
if deps.remove(extra_crate).is_none() {
anyhow::bail!("`{}` not found in [dependencies]", extra_crate);
}
add_feature_to_dep(deps, main_crate, &feature_name)?;
Ok(format!(
"Removed `{}`, enabled `{}` feature on `{}`",
extra_crate, feature_name, 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::{Impact, 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,
},
}
}
#[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);
}
}