diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9330d7a..ce2010c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,4 +4,5 @@ pub mod deploy; pub mod sdk; pub mod config; pub mod setup; -pub mod doctor; \ No newline at end of file +pub mod doctor; +pub mod uninstall; \ No newline at end of file diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs new file mode 100644 index 0000000..1395be4 --- /dev/null +++ b/src/commands/uninstall.rs @@ -0,0 +1,393 @@ +use anyhow::Result; +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; +use colored::*; + +use crate::sdk::SdkConfig; + +#[derive(Debug, Clone)] +enum RemoveTarget { + FtcSdk(PathBuf, String), // path, version label + AndroidSdk(PathBuf), +} + +impl RemoveTarget { + fn label(&self) -> String { + match self { + RemoveTarget::FtcSdk(_, version) => format!("FTC SDK {}", version), + RemoveTarget::AndroidSdk(_) => "Android SDK".to_string(), + } + } + + fn path(&self) -> &PathBuf { + match self { + RemoveTarget::FtcSdk(path, _) => path, + RemoveTarget::AndroidSdk(path) => path, + } + } + + fn size(&self) -> u64 { + dir_size(self.path()) + } +} + +/// Uninstall Weevil-managed dependencies +/// +/// - No args: removes ~/.weevil entirely +/// - --dry-run: shows what would be removed +/// - --only N [N ...]: selective removal of specific components +pub fn uninstall_dependencies(dry_run: bool, targets: Option>) -> Result<()> { + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!("{}", " 🗑️ Weevil Uninstall - Remove Dependencies".bright_cyan().bold()); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!(); + + let sdk_config = SdkConfig::new()?; + + // No --only flag: full uninstall, just nuke .weevil + if targets.is_none() { + return full_uninstall(&sdk_config, dry_run); + } + + // --only flag: selective removal + let all_targets = scan_targets(&sdk_config); + + if all_targets.is_empty() { + println!("{}", "No Weevil-managed components found.".bright_green()); + println!(); + return Ok(()); + } + + // Show numbered list + println!("{}", "Found Weevil-managed components:".bright_yellow().bold()); + println!(); + for (i, target) in all_targets.iter().enumerate() { + println!(" {}. {} — {}", + (i + 1).to_string().bright_cyan().bold(), + target.label(), + format!("{} at {}", format_size(target.size()), target.path().display()).dimmed() + ); + } + println!(); + + // Resolve selected indices + let indices = targets.unwrap(); + let mut selected = Vec::new(); + for idx in indices { + if idx == 0 || idx > all_targets.len() { + println!("{} Invalid selection: {}. Valid range is 1–{}", + "✗".red(), idx, all_targets.len()); + return Ok(()); + } + selected.push(all_targets[idx - 1].clone()); + } + + if dry_run { + print_dry_run(&selected); + return Ok(()); + } + + print_removal_list(&selected); + + if !confirm()? { + return Ok(()); + } + + execute_removal(&selected); + + Ok(()) +} + +/// Full uninstall — removes the entire .weevil directory +fn full_uninstall(sdk_config: &SdkConfig, dry_run: bool) -> Result<()> { + if !sdk_config.cache_dir.exists() { + println!("{}", "No Weevil-managed components found.".bright_green()); + println!(); + return Ok(()); + } + + let size = dir_size(&sdk_config.cache_dir); + + if dry_run { + let all_targets = scan_targets(sdk_config); + + println!("{}", "── Dry Run ─────────────────────────────────────────────────".bright_yellow().bold()); + println!(); + println!("{}", format!("Contents of {}:", sdk_config.cache_dir.display()).bright_yellow().bold()); + println!(); + for (i, target) in all_targets.iter().enumerate() { + println!(" {}. {} — {}", + (i + 1).to_string().bright_cyan().bold(), + target.label(), + format!("{} at {}", format_size(target.size()), target.path().display()).dimmed() + ); + } + + // Note any system-installed dependencies that Weevil doesn't manage + let mut has_external = false; + + if sdk_config.android_sdk_path.exists() + && !sdk_config.android_sdk_path.to_string_lossy().contains(".weevil") { + if !has_external { + println!(); + has_external = true; + } + println!(" {} Android SDK at {} — not managed by Weevil, will not be removed", + "ⓘ".bright_cyan(), + sdk_config.android_sdk_path.display() + ); + } + + if let Ok(gradle_version) = check_gradle() { + if !has_external { + println!(); + } + println!(" {} Gradle {} — not managed by Weevil, will not be removed", + "ⓘ".bright_cyan(), + gradle_version + ); + } + + println!(); + println!("{}", format!("Total: {} ({})", sdk_config.cache_dir.display(), format_size(size)).bright_yellow().bold()); + println!(); + println!("{}", "To remove everything:".bright_yellow().bold()); + println!(" {}", "weevil uninstall".bright_cyan()); + println!(); + println!("{}", "To remove specific items:".bright_yellow().bold()); + println!(" {}", "weevil uninstall --only 1 2".bright_cyan()); + println!(); + return Ok(()); + } + + println!("{}", "This will permanently remove:".bright_yellow().bold()); + println!(); + println!(" {} {} ({})", "✗".red(), sdk_config.cache_dir.display(), format_size(size)); + println!(); + println!("{}", "Everything Weevil installed will be gone.".bright_yellow()); + println!(); + + if !confirm()? { + return Ok(()); + } + + println!(); + print!(" Removing {} ... ", sdk_config.cache_dir.display()); + match fs::remove_dir_all(&sdk_config.cache_dir) { + Ok(_) => { + println!("{}", "✓".green()); + println!(); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!("{}", " ✓ Uninstall Complete".bright_green().bold()); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!(); + println!("{}", "Weevil binary is still installed. To remove it, delete the weevil executable.".bright_yellow()); + println!(); + println!("{}", "To reinstall dependencies later:".bright_yellow().bold()); + println!(" {}", "weevil setup".bright_cyan()); + } + Err(e) => { + println!("{} ({})", "✗".red(), e); + println!(); + println!("{}", "You may need to manually remove this directory.".bright_yellow()); + } + } + + println!(); + Ok(()) +} + +fn print_dry_run(selected: &[RemoveTarget]) { + println!("{}", "── Dry Run ─────────────────────────────────────────────────".bright_yellow().bold()); + println!(); + println!("{}", "The following would be removed:".bright_yellow()); + println!(); + let mut total: u64 = 0; + for target in selected { + let size = target.size(); + total += size; + println!(" {} {} ({})", "✗".red(), target.label(), format_size(size)); + println!(" {}", target.path().display().to_string().dimmed()); + } + println!(); + println!("{}", format!("Total: {}", format_size(total)).bright_yellow().bold()); + println!(); + println!("{}", "Run without --dry-run to actually remove these components.".dimmed()); + println!(); +} + +fn print_removal_list(selected: &[RemoveTarget]) { + println!("{}", "The following will be removed:".bright_yellow().bold()); + println!(); + let mut total: u64 = 0; + for target in selected { + let size = target.size(); + total += size; + println!(" {} {} ({})", "✗".red(), target.label(), format_size(size)); + println!(" {}", target.path().display().to_string().dimmed()); + } + println!(); + println!("{}", format!("Total: {}", format_size(total)).bright_yellow().bold()); + println!(); +} + +fn confirm() -> Result { + print!("{}", "Are you sure you want to continue? (y/N): ".bright_yellow()); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let answer = input.trim().to_lowercase(); + + if answer != "y" && answer != "yes" { + println!(); + println!("{}", "Uninstall cancelled.".bright_green()); + println!(); + return Ok(false); + } + + Ok(true) +} + +fn execute_removal(selected: &[RemoveTarget]) { + println!(); + println!("{}", "Removing components...".bright_yellow()); + println!(); + + let mut removed = Vec::new(); + let mut failed = Vec::new(); + + for target in selected { + print!(" Removing {}... ", target.label()); + match fs::remove_dir_all(target.path()) { + Ok(_) => { + println!("{}", "✓".green()); + removed.push(target.clone()); + } + Err(e) => { + println!("{} ({})", "✗".red(), e); + failed.push((target.clone(), e.to_string())); + } + } + } + + println!(); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + + if failed.is_empty() { + println!("{}", " ✓ Uninstall Complete".bright_green().bold()); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!(); + println!("{}", "Removed:".bright_green().bold()); + for target in &removed { + println!(" {} {}", "✓".green(), target.label()); + } + } else { + println!("{}", " ⚠ Uninstall Completed with Errors".bright_yellow().bold()); + println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); + println!(); + if !removed.is_empty() { + println!("{}", "Removed:".bright_green().bold()); + for target in &removed { + println!(" {} {}", "✓".green(), target.label()); + } + println!(); + } + println!("{}", "Failed to remove:".bright_red().bold()); + for (target, error) in &failed { + println!(" {} {}: {}", "✗".red(), target.label(), error); + } + println!(); + println!("{}", "You may need to manually remove these directories.".bright_yellow()); + } + + println!(); + println!("{}", "To reinstall dependencies later:".bright_yellow().bold()); + println!(" {}", "weevil setup".bright_cyan()); + println!(); +} + +/// Scan the cache directory for individual removable components (used by --only) +fn scan_targets(sdk_config: &SdkConfig) -> Vec { + let mut targets = Vec::new(); + + if sdk_config.cache_dir.exists() { + if let Ok(entries) = fs::read_dir(&sdk_config.cache_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + if name.starts_with("ftc-sdk") { + let version = crate::sdk::ftc::get_version(&path) + .unwrap_or_else(|_| { + if name == "ftc-sdk" { + "default".to_string() + } else { + name.trim_start_matches("ftc-sdk-").to_string() + } + }); + targets.push(RemoveTarget::FtcSdk(path, version)); + } + } + } + } + + // Android SDK — only if Weevil installed it (lives inside .weevil) + if sdk_config.android_sdk_path.exists() + && sdk_config.android_sdk_path.to_string_lossy().contains(".weevil") { + targets.push(RemoveTarget::AndroidSdk(sdk_config.android_sdk_path.clone())); + } + + targets +} + +fn check_gradle() -> Result { + let output = std::process::Command::new("gradle") + .arg("--version") + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if line.starts_with("Gradle") { + return Ok(line.replace("Gradle ", "")); + } + } + Ok("installed (version unknown)".to_string()) + } + Err(_) => anyhow::bail!("Gradle not found"), + } +} + +fn dir_size(path: &PathBuf) -> u64 { + let mut size: u64 = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + size += dir_size(&path); + } else if let Ok(metadata) = path.metadata() { + size += metadata.len(); + } + } + } + size +} + +fn format_size(bytes: u64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } else if bytes >= 1_024 { + format!("{:.1} KB", bytes as f64 / 1_024.0) + } else { + format!("{} B", bytes) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index d078719..b230403 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,17 @@ enum Commands { path: Option, }, + /// Remove Weevil-installed SDKs and dependencies + Uninstall { + /// Show what would be removed without actually removing anything + #[arg(long)] + dry_run: bool, + + /// Remove only specific items by number (use --dry-run first to see the list) + #[arg(long, value_name = "NUM", num_args = 1..)] + only: Option>, + }, + /// Upgrade an existing project to the latest generator version Upgrade { /// Path to the project directory @@ -114,6 +125,9 @@ fn main() -> Result<()> { Commands::Setup { path } => { commands::setup::setup_environment(path.as_deref()) } + Commands::Uninstall { dry_run, only } => { + commands::uninstall::uninstall_dependencies(dry_run, only) + } Commands::Upgrade { path } => { commands::upgrade::upgrade_project(&path) }