1use anyhow::Result;
27use std::collections::HashSet;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use crate::commands::hook;
32use crate::config::Registry;
33use crate::output;
34use crate::setup;
35
36pub fn run(deep: bool, yes: bool) -> Result<()> {
37 output::print_header(if deep {
38 "dev-prune Deep Uninstaller (Full Purge)"
39 } else {
40 "dev-prune Uninstaller"
41 });
42
43 let registry = Registry::load().ok();
44
45 if deep && !yes {
48 use std::io::{IsTerminal, Write};
49 let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
50 output::print_warning(&format!(
51 "This deletes the global config directory (including prune history) and \
52 removes `.devprune.json` from {repo_count} registered repositories."
53 ));
54 if !std::io::stdin().is_terminal() {
55 anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
56 }
57 eprint!("Continue? [y/N]: ");
60 std::io::stderr().flush()?;
61 let mut input = String::new();
62 std::io::stdin().read_line(&mut input)?;
63 if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
64 output::print_info("Deep uninstall cancelled.");
65 return Ok(());
66 }
67 }
68
69 let mut left_behind: Vec<String> = Vec::new();
73 let mut pending_files: Vec<PathBuf> = Vec::new();
77 let mut pending_dirs: Vec<PathBuf> = Vec::new();
78
79 let hands_off = setup::no_auto_setup_requested();
86
87 if hands_off {
89 output::print_info(&format!(
90 "{} is set — leaving the scheduler and agent skills alone.",
91 setup::ENV_NO_AUTO_SETUP
92 ));
93 } else {
94 output::print_info("Removing background daemon scheduler...");
95 if let Err(e) = crate::daemon::uninstall_daemon() {
96 output::print_error(&format!("Background scheduler: {e:#}"));
97 left_behind.push("the background scheduler".to_string());
98 }
99 }
100
101 output::print_info("Removing global Git auto-registration hooks...");
103 if let Err(e) = hook::run_uninstall() {
104 output::print_error(&format!("Git hooks: {e:#}"));
105 left_behind.push("the global Git hooks".to_string());
106 }
107
108 crate::commands::icon::unregister_file_type();
110
111 let skill_roots = if hands_off {
115 Vec::new()
116 } else {
117 setup::agent_skill_roots()
118 };
119 for root in skill_roots {
120 if !root.exists() {
121 continue;
122 }
123 match fs::remove_dir_all(&root) {
124 Ok(()) => output::print_info(&format!(
125 "Removed the agent skill at {}.",
126 output::clean_path(&root)
127 )),
128 Err(e) => {
129 output::print_error(&format!(
130 "Could not remove {}: {e}",
131 output::clean_path(&root)
132 ));
133 left_behind.push("the AI agent skill".to_string());
134 }
135 }
136 }
137
138 if let Ok(bin_dir) = setup::managed_bin_dir() {
142 match crate::pathenv::remove_reachability(&bin_dir) {
143 Ok(true) => output::print_info("Removed dev-prune from your PATH."),
144 Ok(false) => {}
145 Err(e) => {
146 output::print_error(&format!("Could not update your PATH: {e:#}"));
147 left_behind.push("the PATH entry".to_string());
148 }
149 }
150 }
151
152 let manager = std::env::current_exe()
154 .ok()
155 .as_deref()
156 .and_then(owning_package_manager);
157 remove_binaries(
158 deep,
159 manager.is_some(),
160 &mut left_behind,
161 &mut pending_files,
162 &mut pending_dirs,
163 );
164
165 let mut manager_hints: Vec<(&'static str, &'static str)> = Vec::new();
170 if let Some(hint) = manager {
171 manager_hints.push(hint);
172 }
173 sweep_stray_copies(
174 yes,
175 &mut manager_hints,
176 &mut left_behind,
177 &mut pending_files,
178 );
179
180 if deep {
181 if let Some(reg) = registry {
183 for repo_path in reg.repositories.keys() {
184 let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
185 if cfg_file.exists() {
186 let _ = fs::remove_file(cfg_file);
187 }
188 }
189 }
190
191 if let Ok(config_dir) = Registry::config_dir()
192 && config_dir.exists()
193 {
194 match fs::remove_dir_all(&config_dir) {
195 Ok(()) => output::print_info("Removed global configuration directory."),
196 Err(e) => {
197 let running_inside =
202 std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
203 if cfg!(windows) && running_inside {
204 pending_dirs.push(config_dir);
205 } else {
206 output::print_error(&format!(
207 "Could not remove {}: {e}",
208 output::clean_path(&config_dir)
209 ));
210 left_behind.push("the global configuration directory".to_string());
211 }
212 }
213 }
214 }
215 } else {
216 setup::suppress_next_auto_setup();
220 }
221
222 let leftover = spawn_deletion_helper(&pending_files, &pending_dirs);
228 if !pending_files.is_empty() || !pending_dirs.is_empty() {
229 if leftover.len() < pending_files.len() + pending_dirs.len() {
230 output::print_info(
231 "The running binary cannot delete itself — the rest is removed \
232 automatically a few seconds after this command exits.",
233 );
234 }
235 if !leftover.is_empty() {
236 report_manual_removal(&leftover);
237 left_behind.push("the binaries".to_string());
238 }
239 }
240
241 println!();
242 if left_behind.is_empty() {
243 output::print_success(if deep {
244 "Deep uninstall complete: program, integrations, configuration and registry removed."
245 } else {
246 "Uninstall complete: program and integrations removed. Configuration and \
247 prune history preserved for a future reinstall."
248 });
249 }
250 for (name, command) in &manager_hints {
251 output::print_info(&format!(
252 "{name} still lists dev-prune as installed — finish with `{command}` to clear \
253 its records."
254 ));
255 }
256 output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
257
258 if !left_behind.is_empty() {
259 anyhow::bail!("Uninstall finished, but {} is still installed.", {
260 left_behind.join(" and ")
261 });
262 }
263
264 Ok(())
265}
266
267fn remove_binaries(
275 deep: bool,
276 manager_owned: bool,
277 left_behind: &mut Vec<String>,
278 pending_files: &mut Vec<PathBuf>,
279 pending_dirs: &mut Vec<PathBuf>,
280) {
281 let mut candidates: Vec<PathBuf> = Vec::new();
282 let managed_bin_dir = setup::managed_bin_dir().ok();
283
284 if let Some(bin_dir) = &managed_bin_dir {
285 for stem in ["dev-prune", "devp"] {
286 candidates.push(bin_dir.join(exe_name(stem)));
287 }
288 #[cfg(windows)]
291 candidates.push(bin_dir.join(crate::constants::WINDOWS_HIDDEN_BIN));
292 }
293
294 if let Ok(current) = std::env::current_exe() {
295 if is_dev_build(¤t) {
296 output::print_info(
297 "This is a development build — leaving the `target/` binaries alone.",
298 );
299 } else if manager_owned {
300 } else if let Some(parent) = current.parent() {
302 for stem in ["dev-prune", "devp"] {
303 let twin = parent.join(exe_name(stem));
304 if !candidates.contains(&twin) {
305 candidates.push(twin);
306 }
307 }
308 }
309 }
310
311 let mut removed_any = false;
312 for exe in candidates {
313 if !exe.is_file() {
314 continue;
315 }
316 match fs::remove_file(&exe) {
317 Ok(()) => removed_any = true,
318 Err(e) => {
319 if cfg!(windows) && is_in_use_error(&e) {
320 pending_files.push(exe);
321 } else {
322 output::print_error(&format!(
323 "Could not remove {}: {e}",
324 output::clean_path(&exe)
325 ));
326 left_behind.push("the binaries".to_string());
327 }
328 }
329 }
330 }
331 if removed_any {
332 output::print_info("Removed the dev-prune binaries.");
333 }
334
335 if !deep
339 && let Some(bin_dir) = managed_bin_dir
340 && bin_dir.is_dir()
341 && fs::remove_dir(&bin_dir).is_err()
342 && !pending_files.is_empty()
343 {
344 pending_dirs.push(bin_dir);
345 }
346}
347
348struct StrayCopy {
350 path: PathBuf,
351 manager: Option<(&'static str, &'static str)>,
352}
353
354fn sweep_stray_copies(
368 yes: bool,
369 manager_hints: &mut Vec<(&'static str, &'static str)>,
370 left_behind: &mut Vec<String>,
371 pending_files: &mut Vec<PathBuf>,
372) {
373 let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
376 let strays: Vec<StrayCopy> = find_stray_copies()
377 .into_iter()
378 .filter(|s| !already_pending.contains(&canon_key(&s.path)))
379 .collect();
380 if strays.is_empty() {
381 return;
382 }
383
384 println!();
385 output::print_warning(&format!(
386 "Found {} more cop{} of dev-prune, from other install channels:",
387 strays.len(),
388 if strays.len() == 1 { "y" } else { "ies" }
389 ));
390 for stray in &strays {
391 match stray.manager {
392 Some((name, _)) => println!(
393 " {} (installed with {name})",
394 output::clean_path(&stray.path)
395 ),
396 None => println!(" {}", output::clean_path(&stray.path)),
397 }
398 }
399
400 if !confirm_sweep(yes) {
401 output::print_info(
402 "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
403 );
404 return;
405 }
406
407 let mut removed = 0usize;
408 for stray in strays {
409 if let Some(hint) = stray.manager
410 && !manager_hints.iter().any(|(name, _)| *name == hint.0)
411 {
412 manager_hints.push(hint);
413 }
414 match fs::remove_file(&stray.path) {
415 Ok(()) => removed += 1,
416 Err(e) => {
417 if cfg!(windows) && is_in_use_error(&e) {
420 pending_files.push(stray.path);
421 } else {
422 output::print_error(&format!(
423 "Could not remove {}: {e}",
424 output::clean_path(&stray.path)
425 ));
426 left_behind.push("a stray copy".to_string());
427 }
428 }
429 }
430 }
431 if removed > 0 {
432 output::print_info(&format!(
433 "Removed {removed} stray cop{}.",
434 if removed == 1 { "y" } else { "ies" }
435 ));
436 }
437}
438
439fn confirm_sweep(yes: bool) -> bool {
442 use std::io::{IsTerminal, Write};
443 if yes {
444 return true;
445 }
446 if !std::io::stdin().is_terminal() {
447 output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
448 return false;
449 }
450 eprint!("Remove them all? [y/N]: ");
454 if std::io::stderr().flush().is_err() {
455 return false;
456 }
457 let mut input = String::new();
458 if std::io::stdin().read_line(&mut input).is_err() {
459 return false;
460 }
461 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
462}
463
464fn find_stray_copies() -> Vec<StrayCopy> {
470 let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
471 let names = sweep_names();
472 let mut seen_dirs: HashSet<String> = HashSet::new();
473 let mut seen_files: HashSet<String> = HashSet::new();
474 let mut found = Vec::new();
475
476 for dir in sweep_dirs() {
477 let dir_key = canon_key(&dir);
478 if !seen_dirs.insert(dir_key.clone()) {
479 continue;
480 }
481 if managed.as_deref() == Some(dir_key.as_str()) {
482 continue;
483 }
484 for name in &names {
485 let candidate = dir.join(name);
486 let Ok(meta) = fs::symlink_metadata(&candidate) else {
487 continue;
488 };
489 if meta.is_dir() || is_dev_build(&candidate) {
490 continue;
491 }
492 if !seen_files.insert(canon_key(&candidate)) {
493 continue;
494 }
495 let manager = owning_package_manager(&candidate);
496 found.push(StrayCopy {
497 path: candidate,
498 manager,
499 });
500 }
501 }
502 found
503}
504
505fn sweep_dirs() -> Vec<PathBuf> {
509 let mut dirs: Vec<PathBuf> = Vec::new();
510 if let Some(path_var) = std::env::var_os("PATH") {
511 dirs.extend(std::env::split_paths(&path_var));
512 }
513 if setup::no_auto_setup_requested() {
519 if let Ok(exe) = std::env::current_exe()
520 && let Some(parent) = exe.parent()
521 {
522 dirs.push(parent.to_path_buf());
523 }
524 return dirs;
525 }
526 if let Some(home) = dirs::home_dir() {
527 dirs.push(home.join(".cargo").join("bin"));
528 dirs.push(home.join(".local").join("bin"));
529 if !cfg!(windows) {
530 dirs.push(home.join(".npm-global").join("bin"));
531 }
532 }
533 if cfg!(windows) {
534 if let Some(appdata) = dirs::config_dir() {
537 dirs.push(appdata.join("npm"));
538 if let Ok(entries) = fs::read_dir(appdata.join("Python")) {
539 for entry in entries.flatten() {
540 let scripts = entry.path().join("Scripts");
541 if scripts.is_dir() {
542 dirs.push(scripts);
543 }
544 }
545 }
546 }
547 }
548 if let Ok(exe) = std::env::current_exe()
549 && let Some(parent) = exe.parent()
550 {
551 dirs.push(parent.to_path_buf());
552 }
553 dirs
554}
555
556fn sweep_names() -> Vec<String> {
560 let stems = ["dev-prune", "devp"];
561 if cfg!(windows) {
562 let mut names: Vec<String> = Vec::new();
563 for stem in stems {
564 for ext in ["exe", "cmd", "ps1", "bat"] {
565 names.push(format!("{stem}.{ext}"));
566 }
567 names.push(stem.to_string());
568 }
569 names
570 } else {
571 stems.iter().map(|s| s.to_string()).collect()
572 }
573}
574
575fn canon_key(path: &Path) -> String {
577 let key = path.to_string_lossy().replace('\\', "/");
578 let key = key.trim_end_matches('/').to_string();
579 if cfg!(windows) {
580 key.to_lowercase()
581 } else {
582 key
583 }
584}
585
586fn exe_name(stem: &str) -> String {
588 if cfg!(windows) {
589 format!("{stem}.exe")
590 } else {
591 stem.to_string()
592 }
593}
594
595fn is_in_use_error(e: &std::io::Error) -> bool {
600 matches!(e.raw_os_error(), Some(5) | Some(32))
601}
602
603fn is_dev_build(exe: &Path) -> bool {
605 let path = exe.to_string_lossy().replace('\\', "/");
606 path.contains("/target/debug/") || path.contains("/target/release/")
607}
608
609fn owning_package_manager(exe: &Path) -> Option<(&'static str, &'static str)> {
617 let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
618 if path.contains("/.cargo/bin/") {
619 return Some(("cargo", "cargo uninstall dev-prune"));
620 }
621 if path.contains("/node_modules/") || path.contains("/_npx/") {
622 return Some(("npm", "npm uninstall -g dev-prune"));
623 }
624 if path.contains("/uv/tools/") {
625 return Some(("uv", "uv tool uninstall dev-prune"));
626 }
627 if path.contains("/pipx/") {
628 return Some(("pipx", "pipx uninstall dev-prune"));
629 }
630 if let Some(dir) = exe.parent() {
631 if dir.join("node_modules").join("dev-prune").exists() {
633 return Some(("npm", "npm uninstall -g dev-prune"));
634 }
635 for interpreter in ["python.exe", "python", "python3"] {
638 if dir.join(interpreter).exists() {
639 return Some(("pip", "pip uninstall dev-prune"));
640 }
641 }
642 }
643 None
644}
645
646fn reinstall_hint() -> &'static str {
648 if cfg!(windows) {
649 "iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex"
650 } else {
651 "curl -fsSL https://devprune.vkrishna04.me/install.sh | sh"
652 }
653}
654
655fn report_manual_removal(paths: &[PathBuf]) {
663 output::print_error(&format!(
664 "{} item(s) are still in use and could not be scheduled for removal.",
665 paths.len()
666 ));
667 for path in paths {
668 let meta = fs::symlink_metadata(path).ok();
669 let kind = match meta.as_ref() {
670 Some(m) if m.is_dir() => "directory".to_string(),
671 Some(m) => format!("file, {}", output::format_bytes(m.len())),
672 None => "already gone".to_string(),
673 };
674 let name = path
675 .file_name()
676 .map(|n| n.to_string_lossy().into_owned())
677 .unwrap_or_else(|| output::clean_path(path));
678 let parent = path
679 .parent()
680 .map(output::clean_path)
681 .unwrap_or_else(|| "—".to_string());
682 println!(" {name} ({kind})");
683 println!(" in {parent}");
684 }
685 println!("\n Remove them yourself with:");
686 for path in paths {
687 println!(
691 " Remove-Item -LiteralPath '{}' -Recurse -Force",
692 path.display().to_string().replace('\'', "''")
693 );
694 }
695}
696
697#[cfg(windows)]
705fn ps_quote(path: &Path) -> String {
706 format!("'{}'", path.display().to_string().replace('\'', "''"))
707}
708
709#[cfg(windows)]
719fn spawn_deletion_helper(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<PathBuf> {
720 if files.is_empty() && dirs.is_empty() {
721 return Vec::new();
722 }
723 if spawn_powershell_helper(files, dirs) {
724 return Vec::new();
725 }
726
727 let has_percent = |p: &&PathBuf| p.to_string_lossy().contains('%');
730 let left_behind: Vec<PathBuf> = files
731 .iter()
732 .chain(dirs.iter())
733 .filter(has_percent)
734 .cloned()
735 .collect();
736 let safe_files: Vec<PathBuf> = files.iter().filter(|p| !has_percent(p)).cloned().collect();
737 let safe_dirs: Vec<PathBuf> = dirs.iter().filter(|p| !has_percent(p)).cloned().collect();
738
739 if (safe_files.is_empty() && safe_dirs.is_empty()) || spawn_cmd_helper(&safe_files, &safe_dirs)
740 {
741 left_behind
742 } else {
743 files.iter().chain(dirs.iter()).cloned().collect()
744 }
745}
746
747#[cfg(windows)]
749fn spawn_powershell_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
750 use std::os::windows::process::CommandExt;
751 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
752
753 let mut attempt = String::new();
754 for file in files {
755 attempt.push_str(&format!(
756 "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue; ",
757 ps_quote(file)
758 ));
759 }
760 for dir in dirs {
761 attempt.push_str(&format!(
762 "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue; ",
763 ps_quote(dir)
764 ));
765 }
766
767 let mut script = String::new();
769 for _ in 0..3 {
770 script.push_str("Start-Sleep -Seconds 2; ");
771 script.push_str(&attempt);
772 }
773
774 for program in [
781 crate::spawn::system32(r"WindowsPowerShell\v1.0\powershell.exe"),
782 String::from("pwsh.exe"),
783 String::from("pwsh-preview.exe"),
784 String::from("powershell.exe"),
785 ] {
786 let spawned = std::process::Command::new(&program)
787 .args(["-NoProfile", "-NonInteractive", "-Command"])
788 .arg(&script)
789 .creation_flags(CREATE_NO_WINDOW)
790 .stdin(std::process::Stdio::null())
791 .stdout(std::process::Stdio::null())
792 .stderr(std::process::Stdio::null())
793 .spawn()
794 .is_ok();
795 if spawned {
796 return true;
797 }
798 }
799 false
800}
801
802#[cfg(windows)]
805fn spawn_cmd_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
806 use std::os::windows::process::CommandExt;
807 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
810
811 let mut attempt = String::new();
817 for file in files {
818 attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
819 }
820 for dir in dirs {
821 attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
822 }
823 let mut script = String::new();
824 for _ in 0..3 {
825 script.push_str("ping -n 3 127.0.0.1 >nul");
826 script.push_str(&attempt);
827 script.push_str(" & ");
828 }
829 script.push_str("exit");
830
831 std::process::Command::new(crate::spawn::system32("cmd.exe"))
832 .raw_arg(format!("/C {script}"))
835 .creation_flags(CREATE_NO_WINDOW)
836 .stdin(std::process::Stdio::null())
837 .stdout(std::process::Stdio::null())
838 .stderr(std::process::Stdio::null())
839 .spawn()
840 .is_ok()
841}
842
843#[cfg(not(windows))]
846fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> Vec<PathBuf> {
847 Vec::new()
848}