Skip to main content

dev_prune/commands/
uninstall.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune uninstall`.
5//
6// Two modes, and both of them remove the program itself — an uninstall that leaves a
7// fully working binary on PATH is not an uninstall, it is a settings change:
8//
9// - Light (default): removes the scheduler, the Git hooks, the file-type icons, the
10//   agent skill, the PATH entry and the binaries. The config directory — registry,
11//   prune history, settings — is kept, so a later reinstall picks up where it left off.
12// - Deep (`--deep`): all of the above, plus `.devprune.json` in every registered
13//   repository and the config directory itself.
14//
15// Both modes also sweep for *other* copies of the pair — a machine that has tried
16// `pip install`, `cargo install` and the shell installer over time has binaries and
17// shims in `~/.cargo/bin`, `~/.local/bin`, npm's global directory, a venv's `Scripts`
18// — and offers to delete every one it finds, so "uninstall" means the command stops
19// resolving everywhere, not just in the managed directory.
20//
21// A copy a package manager installed is removed by *that manager's* uninstall, never
22// by deleting the file: with the file gone, `cargo uninstall dev-prune` exits 101 with
23// `corrupt metadata, ... does not exist when it should` and leaves its ledger entry
24// standing, so the command printed as the remedy can no longer succeed.
25//
26// On Windows a running executable cannot delete itself. dev-prune does not work around
27// that by leaving a shell behind to finish the job after it exits: that shape is the
28// textbook self-delete, and it is what gets an unsigned binary quarantined. It renames
29// the locked image aside instead — which Windows does allow — hands the residue to the
30// session manager for the next boot, and prints whatever survives both.
31//
32// A package manager's own uninstall cannot be rescued the same way. While its binary is
33// executing, `cargo uninstall` fails with `Access is denied` and keeps its ledger entry,
34// and renaming the file aside first only trades that failure for the `corrupt metadata`
35// one, which keeps the entry too. Exit first, then uninstall, is the only order that
36// clears the record, so that command is printed for the user rather than run.
37//
38// Neither is a failure: the command reports both and exits `0`.
39
40use anyhow::{Context, Result};
41use std::collections::HashSet;
42use std::fs;
43use std::path::{Path, PathBuf};
44
45use crate::channel::Channel;
46use crate::commands::hook;
47use crate::config::Registry;
48use crate::output;
49use crate::setup;
50
51pub fn run(deep: bool, yes: bool) -> Result<()> {
52    output::print_header(if deep {
53        "dev-prune Deep Uninstaller (Full Purge)"
54    } else {
55        "dev-prune Uninstaller"
56    });
57
58    let registry = Registry::load().ok();
59
60    // A deep uninstall deletes files inside the user's own repositories and destroys
61    // the prune history. That is not something to do on a mistyped flag.
62    if deep && !yes {
63        use std::io::{IsTerminal, Write};
64        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
65        output::print_warning(&format!(
66            "This deletes the global config directory (including prune history) and \
67             removes `.devprune.json` from {repo_count} registered repositories."
68        ));
69        if !std::io::stdin().is_terminal() {
70            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
71        }
72        // stderr, like every other confirmation: with stdout piped the question would
73        // vanish into the pipe and the command would appear to hang.
74        eprint!("Continue? [y/N]: ");
75        std::io::stderr().flush()?;
76        let mut input = String::new();
77        std::io::stdin().read_line(&mut input)?;
78        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
79            output::print_info("Deep uninstall cancelled.");
80            return Ok(());
81        }
82    }
83
84    // Each step keeps going when another fails — a scheduler that refuses to uninstall
85    // must not stop the hooks being removed — but none of them is silent about it. What
86    // could not be removed is reported, and its presence makes the exit code `1`.
87    let mut left_behind: Vec<String> = Vec::new();
88    // Files and directories that are in use right now (Windows keeps a running image
89    // locked, under every one of its hard-linked names). Deleted by a detached helper
90    // the moment this process exits.
91    let mut pending_files: Vec<PathBuf> = Vec::new();
92    let mut pending_dirs: Vec<PathBuf> = Vec::new();
93
94    // `DEV_PRUNE_NO_AUTO_SETUP` means "dev-prune manages nothing on this machine" — and
95    // that has to cut both ways. If the variable stopped setup from registering a
96    // scheduler or writing into agent skill directories, then uninstall must not reach
97    // for them either: whatever is there was put there by hand (or by another install
98    // this process knows nothing about), and hands-off means hands-off. It is also what
99    // lets the test suite run this command against a real machine.
100    let hands_off = setup::no_auto_setup_requested();
101
102    // 1. Background scheduler.
103    if hands_off {
104        output::print_info(&format!(
105            "{} is set — leaving the scheduler and agent skills alone.",
106            setup::ENV_NO_AUTO_SETUP
107        ));
108    } else {
109        output::print_info("Removing background daemon scheduler...");
110        if let Err(e) = crate::daemon::uninstall_daemon() {
111            output::print_error(&format!("Background scheduler: {e:#}"));
112            left_behind.push("the background scheduler".to_string());
113        }
114    }
115
116    // 2. Global Git hooks.
117    output::print_info("Removing global Git auto-registration hooks...");
118    if let Err(e) = hook::run_uninstall() {
119        output::print_error(&format!("Git hooks: {e:#}"));
120        left_behind.push("the global Git hooks".to_string());
121    }
122
123    // 3. The `*.devprune.json` file type, out of the desktop database.
124    crate::commands::icon::unregister_file_type();
125
126    // 4. The skill installed into AI agents' own directories. Only directories named
127    // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling — and
128    // none at all under hands-off, for the same reason as the scheduler above.
129    let skill_roots = if hands_off {
130        Vec::new()
131    } else {
132        setup::agent_skill_roots()
133    };
134    for root in skill_roots {
135        if !root.exists() {
136            continue;
137        }
138        match fs::remove_dir_all(&root) {
139            Ok(()) => output::print_info(&format!(
140                "Removed the agent skill at {}.",
141                output::clean_path(&root)
142            )),
143            Err(e) => {
144                output::print_error(&format!(
145                    "Could not remove {}: {e}",
146                    output::clean_path(&root)
147                ));
148                left_behind.push("the AI agent skill".to_string());
149            }
150        }
151    }
152
153    // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
154    // elsewhere. Before the binaries go, so no window exists where PATH names a
155    // directory whose contents are gone.
156    if let Ok(bin_dir) = setup::managed_bin_dir() {
157        match crate::pathenv::remove_reachability(&bin_dir) {
158            Ok(true) => output::print_info("Removed dev-prune from your PATH."),
159            Ok(false) => {}
160            Err(e) => {
161                output::print_error(&format!("Could not update your PATH: {e:#}"));
162                left_behind.push("the PATH entry".to_string());
163            }
164        }
165    }
166
167    // 6. The binaries themselves.
168    let channel = Channel::detect();
169    remove_binaries(
170        deep,
171        channel.owns_its_files(),
172        &mut left_behind,
173        &mut pending_files,
174        &mut pending_dirs,
175    );
176
177    // 7. Every other copy on the machine. A machine that has tried more than one
178    // install channel has more than one binary, and the ones not currently first on
179    // PATH would quietly *become* the installation the moment the managed pair above
180    // is gone.
181    //
182    // The channel this binary came from needs no separate handling: the sweep looks in
183    // the running executable's own directory, so a manager-owned copy is found there
184    // like any other and removed the same way.
185    let mut manager_hints: Vec<Channel> = Vec::new();
186    let mut pending_commands: Vec<Channel> = Vec::new();
187    sweep_stray_copies(
188        yes,
189        &mut manager_hints,
190        &mut left_behind,
191        &mut pending_files,
192        &mut pending_commands,
193    );
194
195    if deep {
196        // Per-repo configs, then the config directory itself.
197        //
198        // Only the personal file. `project.devprune.json` is a tracked file somebody
199        // committed, and uninstalling a tool from one machine is not a mandate to delete
200        // a file from a shared repository -- the deletion would show up in `git status`
201        // on a branch the user never meant to touch, and reach their colleagues on the
202        // next push.
203        if let Some(reg) = registry {
204            for repo_path in reg.repositories.keys() {
205                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
206                if cfg_file.exists() {
207                    let _ = fs::remove_file(cfg_file);
208                }
209            }
210        }
211
212        if let Ok(config_dir) = Registry::config_dir()
213            && config_dir.exists()
214        {
215            match fs::remove_dir_all(&config_dir) {
216                Ok(()) => output::print_info("Removed global configuration directory."),
217                Err(e) => {
218                    // Routine on Windows: the managed copy under `<config>/bin` is
219                    // often the very binary running this command, and a running
220                    // executable cannot be deleted. That case is finished by the
221                    // helper; anything else really is left behind.
222                    let running_inside =
223                        std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
224                    if cfg!(windows) && running_inside {
225                        pending_dirs.push(config_dir);
226                    } else {
227                        output::print_error(&format!(
228                            "Could not remove {}: {e}",
229                            output::clean_path(&config_dir)
230                        ));
231                        left_behind.push("the global configuration directory".to_string());
232                    }
233                }
234            }
235        }
236    } else {
237        // Stamp the current version so a surviving copy (a package-manager install, a
238        // dev build) does not reinstall, on its very next command, everything this
239        // command was run to remove.
240        setup::suppress_next_auto_setup();
241    }
242
243    // 8. Everything above that Windows refused because this very process holds the file
244    // open. It is finished here, in this process, or it is reported -- never handed to
245    // a background shell. `finish_locked_removals` has the reasoning.
246    let (residue, still_installed) = finish_locked_removals(&pending_files, &pending_dirs);
247    if !residue.is_empty() {
248        report_manual_removal(&residue);
249    } else if !pending_files.is_empty() || !pending_dirs.is_empty() {
250        output::print_info(
251            "The binary running this command cannot delete itself. Its name is free \
252             again, and Windows removes what is left of the file at the next restart.",
253        );
254    }
255    if still_installed {
256        left_behind.push("the binaries".to_string());
257    }
258    // A package manager cannot uninstall a binary that is executing either, and no
259    // ordering inside this process changes that -- see the note at the top of the file.
260    // Its command goes back onto the list printed below, for the user to run once this
261    // command has exited.
262    for channel in &pending_commands {
263        if !manager_hints.contains(channel) {
264            manager_hints.push(*channel);
265        }
266    }
267
268    println!();
269    if left_behind.is_empty() {
270        output::print_success(if deep {
271            "Deep uninstall complete: program, integrations, configuration and registry removed."
272        } else {
273            "Uninstall complete: program and integrations removed. Configuration and \
274             prune history preserved for a future reinstall."
275        });
276    }
277    for hint in &manager_hints {
278        let Some(command) = hint.uninstall_command() else {
279            continue;
280        };
281        output::print_info(&format!(
282            "{} still lists dev-prune as installed — finish with `{command}` to clear \
283             its records.",
284            hint.label()
285        ));
286    }
287    output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
288
289    if !left_behind.is_empty() {
290        anyhow::bail!("Uninstall finished, but {} is still installed.", {
291            left_behind.join(" and ")
292        });
293    }
294
295    Ok(())
296}
297
298/// Delete the managed pair, and the pair beside the running executable.
299///
300/// Skips a development build outright — deleting `target/debug/dev-prune` because a
301/// test or a contributor ran `uninstall` would destroy the build being worked on — and
302/// skips the copy beside a package-manager-owned executable, which the sweep offers to
303/// delete *with confirmation* rather than silently, because pulling files out from
304/// under a manager leaves its records dangling until its own uninstall command runs.
305fn remove_binaries(
306    deep: bool,
307    manager_owned: bool,
308    left_behind: &mut Vec<String>,
309    pending_files: &mut Vec<PathBuf>,
310    pending_dirs: &mut Vec<PathBuf>,
311) {
312    let mut candidates: Vec<PathBuf> = Vec::new();
313    let managed_bin_dir = setup::managed_bin_dir().ok();
314
315    if let Some(bin_dir) = &managed_bin_dir {
316        for stem in ["dev-prune", "devp"] {
317            candidates.push(bin_dir.join(exe_name(stem)));
318        }
319        // The windowless scheduler twin, which ships in the Windows archives as its own
320        // build target. `WINDOWS_WINDOWLESS_BIN` already carries its `.exe`.
321        #[cfg(windows)]
322        candidates.push(bin_dir.join(crate::constants::WINDOWS_WINDOWLESS_BIN));
323    }
324
325    if let Ok(current) = std::env::current_exe() {
326        if is_dev_build(&current) {
327            output::print_info(
328                "This is a development build — leaving the `target/` binaries alone.",
329            );
330        } else if manager_owned {
331            // The sweep below removes it, by running the manager's own uninstall.
332            // Deleting it here would destroy that command's precondition.
333        } else if let Some(parent) = current.parent() {
334            for stem in ["dev-prune", "devp"] {
335                let twin = parent.join(exe_name(stem));
336                if !candidates.contains(&twin) {
337                    candidates.push(twin);
338                }
339            }
340        }
341    }
342
343    let mut removed_any = false;
344    for exe in candidates {
345        if !exe.is_file() {
346            continue;
347        }
348        match fs::remove_file(&exe) {
349            Ok(()) => removed_any = true,
350            Err(e) => {
351                if cfg!(windows) && is_in_use_error(&e) {
352                    pending_files.push(exe);
353                } else {
354                    output::print_error(&format!(
355                        "Could not remove {}: {e}",
356                        output::clean_path(&exe)
357                    ));
358                    left_behind.push("the binaries".to_string());
359                }
360            }
361        }
362    }
363    if removed_any {
364        output::print_info("Removed the dev-prune binaries.");
365    }
366
367    // The install receipt describes the binary in this directory and nothing else, so it
368    // goes when that binary goes. Left behind it would outlive its subject, and it would
369    // also keep the directory below from ever being empty.
370    if let Some(bin_dir) = &managed_bin_dir {
371        let _ = fs::remove_file(bin_dir.join(crate::constants::INSTALL_RECEIPT_FILE));
372    }
373
374    // The managed `bin` directory should not outlive its contents. On a deep uninstall
375    // the whole config directory goes anyway; on a light one, remove it once empty, or
376    // let the helper do it after the pending deletions.
377    if !deep
378        && let Some(bin_dir) = managed_bin_dir
379        && bin_dir.is_dir()
380        && fs::remove_dir(&bin_dir).is_err()
381        && !pending_files.is_empty()
382    {
383        pending_dirs.push(bin_dir);
384    }
385}
386
387/// One copy of dev-prune found somewhere other than the managed directory.
388pub(crate) struct StrayCopy {
389    pub(crate) path: PathBuf,
390    pub(crate) channel: Channel,
391}
392
393/// Find every other copy of the pair, show the list, and — with the user's yes —
394/// delete them all.
395///
396/// Discovery covers every directory on this process's PATH plus the well-known install
397/// directories that are often *not* on it any more: `~/.cargo/bin`, `~/.local/bin`
398/// (uv, pipx and the XDG convention), npm's global directory, pip's per-user `Scripts`
399/// directories, and whatever directory the running executable lives in. Only files
400/// carrying the pair's own names are ever considered, so nothing else in those
401/// directories can be touched.
402///
403/// Deletion is opt-in: the list is printed and confirmed first (`--yes` counts as
404/// confirmation; a non-terminal without it leaves everything in place). A declined
405/// prompt is a decision, not a failure — it does not change the exit code.
406///
407/// What removal *means* depends on who owns the file, and there are three answers, not
408/// two. A copy in a location nothing claims is deleted outright, because the file is the
409/// whole install. A copy a manager installed is removed by that manager's own uninstall
410/// — see [`Channel::uninstall_argv`] for why deleting it directly is worse than leaving
411/// it. And a copy inside a manager dev-prune knows by name but cannot drive is named and
412/// left, which is the same reasoning with no command at the end of it.
413fn sweep_stray_copies(
414    yes: bool,
415    manager_hints: &mut Vec<Channel>,
416    left_behind: &mut Vec<String>,
417    pending_files: &mut Vec<PathBuf>,
418    pending_commands: &mut Vec<Channel>,
419) {
420    // Anything already queued for the deletion helper still exists on disk right now;
421    // finding it again here would list it as a stray and queue it twice.
422    let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
423    let strays: Vec<StrayCopy> = find_stray_copies()
424        .into_iter()
425        .filter(|s| !already_pending.contains(&canon_key(&s.path)))
426        .collect();
427    if strays.is_empty() {
428        return;
429    }
430
431    println!();
432    output::print_warning(&format!(
433        "Found {} more cop{} of dev-prune, from other install channels:",
434        strays.len(),
435        if strays.len() == 1 { "y" } else { "ies" }
436    ));
437    for stray in &strays {
438        if stray.channel.owns_its_files() {
439            println!(
440                "   {}  (installed with {})",
441                output::clean_path(&stray.path),
442                stray.channel.label()
443            );
444        } else {
445            println!("   {}", output::clean_path(&stray.path));
446        }
447    }
448
449    // Every manager whose copy turned up. Entries come off this list as each
450    // manager's own uninstall runs or is scheduled; whatever is left over is printed
451    // at the end for the user to finish by hand.
452    for stray in &strays {
453        if stray.channel.owns_its_files() && !manager_hints.contains(&stray.channel) {
454            manager_hints.push(stray.channel);
455        }
456    }
457
458    if !confirm_sweep(yes) {
459        output::print_info(
460            "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
461        );
462        return;
463    }
464
465    let running = std::env::current_exe().ok().map(|p| canon_key(&p));
466    let mut removed = 0usize;
467    for (channel, paths) in group_by_channel(strays) {
468        let Some(argv) = channel.uninstall_argv() else {
469            // Two channels have no command, and only one of them may be deleted. A
470            // `Foreign` copy is inside somebody's package tree; removing the file would
471            // leave that manager listing a binary that is gone, with no `uninstall_argv`
472            // to repair it afterwards.
473            if !channel.may_delete_directly() {
474                let who = channel.label();
475                output::print_warning(&format!(
476                    "{who} installed {} of the copies above, and dev-prune does not know \
477                     {who}'s uninstall command. Left in place: deleting the file would \
478                     leave {who} listing a binary that is gone.",
479                    paths.len()
480                ));
481                continue;
482            }
483            // Nobody holds a record of these, so the file is the whole install.
484            for path in paths {
485                match fs::remove_file(&path) {
486                    Ok(()) => removed += 1,
487                    Err(e) => {
488                        // The running executable itself is often in this list. Windows
489                        // keeps it locked; the detached helper finishes the job.
490                        if cfg!(windows) && is_in_use_error(&e) {
491                            pending_files.push(path);
492                        } else {
493                            output::print_error(&format!(
494                                "Could not remove {}: {e}",
495                                output::clean_path(&path)
496                            ));
497                            left_behind.push("a stray copy".to_string());
498                        }
499                    }
500                }
501            }
502            continue;
503        };
504
505        // Without the manager on PATH there is no way to clear its record, and deleting
506        // the file would make the record unclearable. Leaving it is the only move that
507        // keeps the machine recoverable.
508        if !crate::adapters::binary_available(&argv[0]) {
509            output::print_warning(&format!(
510                "{} is not on PATH, so its copy was left in place.",
511                channel.label()
512            ));
513            continue;
514        }
515
516        // Windows will not let a manager delete a binary that is executing, and there
517        // is no way to get around it from inside that binary — see the note at the top
518        // of this file. Scheduled, not skipped.
519        if cfg!(windows) && paths.iter().any(|p| Some(canon_key(p)) == running) {
520            pending_commands.push(channel);
521            manager_hints.retain(|c| *c != channel);
522            continue;
523        }
524
525        match run_manager_uninstall(&argv) {
526            Ok(()) => {
527                manager_hints.retain(|c| *c != channel);
528                removed += paths.len();
529                // Whatever the manager did not take is safe to delete now: its record
530                // is clear, so the file is no longer part of an install.
531                for path in paths {
532                    if fs::symlink_metadata(&path).is_ok() {
533                        let _ = fs::remove_file(&path);
534                    }
535                }
536            }
537            Err(e) => {
538                output::print_error(&format!("`{}` failed: {e:#}", argv.join(" ")));
539                left_behind.push(format!("the {} copy", channel.label()));
540            }
541        }
542    }
543    if removed > 0 {
544        output::print_info(&format!(
545            "Removed {removed} stray cop{}.",
546            if removed == 1 { "y" } else { "ies" }
547        ));
548    }
549}
550
551/// Group the strays by the manager that owns them, keeping discovery order.
552///
553/// One manager is told once, however many of its files turned up. `~/.cargo/bin` holds
554/// both `dev-prune` and `devp`, and a second `cargo uninstall dev-prune` exits 101 with
555/// "package ID specification did not match any packages" — a failure to report, from a
556/// command that had in fact already worked.
557pub(crate) fn group_by_channel(strays: Vec<StrayCopy>) -> Vec<(Channel, Vec<PathBuf>)> {
558    let mut groups: Vec<(Channel, Vec<PathBuf>)> = Vec::new();
559    for stray in strays {
560        match groups.iter_mut().find(|(c, _)| *c == stray.channel) {
561            Some((_, paths)) => paths.push(stray.path),
562            None => groups.push((stray.channel, vec![stray.path])),
563        }
564    }
565    groups
566}
567
568/// Run a package manager's own uninstall, wired to this terminal so its progress and
569/// its errors are the user's to read.
570fn run_manager_uninstall(argv: &[String]) -> Result<()> {
571    output::print_info(&format!("Running: {}", argv.join(" ")));
572    let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
573        .args(&argv[1..])
574        .status()
575        .with_context(|| format!("could not start `{}`", argv[0]))?;
576    if !status.success() {
577        anyhow::bail!("exited with {status}");
578    }
579    Ok(())
580}
581
582/// Ask before the sweep deletes anything. `--yes` answers for the user; a pipe or a
583/// script without it gets a "no" plus the flag to pass next time.
584fn confirm_sweep(yes: bool) -> bool {
585    use std::io::{IsTerminal, Write};
586    if yes {
587        return true;
588    }
589    if !std::io::stdin().is_terminal() {
590        output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
591        return false;
592    }
593    // Default no, like every other deletion prompt in this tool: these files live in
594    // directories dev-prune does not manage, and a reflexive Enter should never be
595    // what deletes them. The question goes to stderr so a piped stdout cannot eat it.
596    eprint!("Remove them all? [y/N]: ");
597    if std::io::stderr().flush().is_err() {
598        return false;
599    }
600    let mut input = String::new();
601    if std::io::stdin().read_line(&mut input).is_err() {
602        return false;
603    }
604    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
605}
606
607/// Every dev-prune/devp file in the sweep directories, except the managed pair (the
608/// caller already removed it), development builds, and directories, deduplicated.
609///
610/// A dangling symlink still counts — it is exactly the kind of leftover the sweep
611/// exists to clean up — which is why this checks `symlink_metadata`, not `is_file`.
612pub(crate) fn find_stray_copies() -> Vec<StrayCopy> {
613    let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
614    let managed_exe = setup::managed_exe_path().ok();
615    let names = sweep_names();
616    let mut seen_dirs: HashSet<String> = HashSet::new();
617    let mut seen_files: HashSet<String> = HashSet::new();
618    let mut found = Vec::new();
619
620    for dir in sweep_dirs() {
621        let dir_key = canon_key(&dir);
622        if !seen_dirs.insert(dir_key.clone()) {
623            continue;
624        }
625        if managed.as_deref() == Some(dir_key.as_str()) {
626            continue;
627        }
628        for name in &names {
629            let candidate = dir.join(name);
630            let Ok(meta) = fs::symlink_metadata(&candidate) else {
631                continue;
632            };
633            if meta.is_dir() || is_dev_build(&candidate) {
634                continue;
635            }
636            if !seen_files.insert(canon_key(&candidate)) {
637                continue;
638            }
639            let channel = Channel::detect_at(&candidate, managed_exe.as_deref());
640            found.push(StrayCopy {
641                path: candidate,
642                channel,
643            });
644        }
645    }
646    found
647}
648
649/// The directories worth looking in: everything on PATH, plus the install directories
650/// each supported channel writes to — which stop being on PATH the moment a venv
651/// deactivates or a profile line is removed, without the files going anywhere.
652fn sweep_dirs() -> Vec<PathBuf> {
653    let mut dirs: Vec<PathBuf> = Vec::new();
654    if let Some(path_var) = std::env::var_os("PATH") {
655        dirs.extend(std::env::split_paths(&path_var));
656    }
657    // Under hands-off the sweep stays inside directories the caller's own environment
658    // names. `PATH` is the caller's to shape; the home-derived extras below are this
659    // code guessing at install locations, which is exactly the reaching-around that
660    // `DEV_PRUNE_NO_AUTO_SETUP` turns off — and what keeps the test suite out of the
661    // developer's real `~/.cargo/bin`.
662    if setup::no_auto_setup_requested() {
663        if let Ok(exe) = std::env::current_exe()
664            && let Some(parent) = exe.parent()
665        {
666            dirs.push(parent.to_path_buf());
667        }
668        return dirs;
669    }
670    dirs.extend(crate::channel::install_dirs(dirs::home_dir().as_deref()));
671    if cfg!(windows) {
672        // `config_dir` is %APPDATA% — pip's per-user scripts live under it, one
673        // directory per interpreter version, so they have to be enumerated rather than
674        // named.
675        if let Some(appdata) = dirs::config_dir()
676            && let Ok(entries) = fs::read_dir(appdata.join("Python"))
677        {
678            for entry in entries.flatten() {
679                let scripts = entry.path().join("Scripts");
680                if scripts.is_dir() {
681                    dirs.push(scripts);
682                }
683            }
684        }
685    }
686    if let Ok(exe) = std::env::current_exe()
687        && let Some(parent) = exe.parent()
688    {
689        dirs.push(parent.to_path_buf());
690    }
691    dirs
692}
693
694/// The file names one of the pair can appear under. On Windows that is more than the
695/// two `.exe`s: npm writes `.cmd` and `.ps1` shims plus an extensionless sh shim for
696/// Git Bash, and each is a separate file to delete.
697fn sweep_names() -> Vec<String> {
698    // `devpw` is the windowless scheduler twin. It is a build target like the other
699    // two, so `cargo install` puts it on PATH beside them and an uninstall that did not
700    // look for it would leave a working copy of the whole CLI behind under a name
701    // nobody thinks to check.
702    let stems = ["dev-prune", "devp", "devpw"];
703    if cfg!(windows) {
704        let mut names: Vec<String> = Vec::new();
705        for stem in stems {
706            // `exe.old` is dev-prune's own debris: an update renames the running binary
707            // aside so the channel can write a fresh one at the real name, and the
708            // delete that follows is best-effort because the file is still the running
709            // image. The next update sweeps it -- but somebody who updates once and then
710            // uninstalls never has a next update, and the orphan outlives the install.
711            // `.new` likewise: an update stages the downloaded bytes beside the target
712            // and renames them in, and a stage that failed to rename is debris nothing
713            // else ever looks at.
714            for ext in ["exe", "cmd", "ps1", "bat", "exe.old", "new"] {
715                names.push(format!("{stem}.{ext}"));
716            }
717            names.push(stem.to_string());
718        }
719        names
720    } else {
721        stems
722            .iter()
723            .flat_map(|s| [s.to_string(), format!("{s}.new")])
724            .collect()
725    }
726}
727
728/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
729pub(crate) fn canon_key(path: &Path) -> String {
730    let key = path.to_string_lossy().replace('\\', "/");
731    let key = key.trim_end_matches('/').to_string();
732    if cfg!(windows) {
733        key.to_lowercase()
734    } else {
735        key
736    }
737}
738
739/// The on-disk file name for one of the pair, on this platform.
740fn exe_name(stem: &str) -> String {
741    if cfg!(windows) {
742        format!("{stem}.exe")
743    } else {
744        stem.to_string()
745    }
746}
747
748/// Whether a deletion failure means "in use right now" — the one case the detached
749/// helper can finish. 5 is ERROR_ACCESS_DENIED, which is what deleting the running
750/// image reports; 32 is ERROR_SHARING_VIOLATION. Anything else (read-only media, a
751/// policy block) the helper would only inherit, so it is reported instead of queued.
752fn is_in_use_error(e: &std::io::Error) -> bool {
753    matches!(e.raw_os_error(), Some(5) | Some(32))
754}
755
756/// Whether this executable is running out of a Cargo build directory.
757fn is_dev_build(exe: &Path) -> bool {
758    let path = exe.to_string_lossy().replace('\\', "/");
759    path.contains("/target/debug/") || path.contains("/target/release/")
760}
761
762/// The install one-liner for this platform, for the goodbye message.
763fn reinstall_hint() -> String {
764    if cfg!(windows) {
765        format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
766    } else {
767        format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
768    }
769}
770
771/// List what could not be scheduled, with enough detail to act on, plus the command
772/// that removes it.
773///
774/// The stray-copy sweep lists every path before it deletes anything; this is the same
775/// courtesy for the residue. "Some files could not be removed" leaves someone hunting
776/// through Program Files for a name they were never told, so each line carries the
777/// name, the directory it sits in, what kind of thing it is and how big it is.
778fn report_manual_removal(paths: &[PathBuf]) {
779    output::print_warning(&format!(
780        "{} item(s) were open in this process and could not be removed from inside it. \
781         Delete them once this command has exited, or restart — from an elevated shell \
782         Windows would have taken them at the next boot on its own.",
783        paths.len()
784    ));
785    for path in paths {
786        let meta = fs::symlink_metadata(path).ok();
787        let kind = match meta.as_ref() {
788            Some(m) if m.is_dir() => "directory".to_string(),
789            Some(m) => format!("file, {}", output::format_bytes(m.len())),
790            None => "already gone".to_string(),
791        };
792        let name = path
793            .file_name()
794            .map(|n| n.to_string_lossy().into_owned())
795            .unwrap_or_else(|| output::clean_path(path));
796        let parent = path
797            .parent()
798            .map(output::clean_path)
799            .unwrap_or_else(|| "—".to_string());
800        println!("    {name}  ({kind})");
801        println!("      in {parent}");
802    }
803    println!("\n  Remove them yourself with:");
804    for path in paths {
805        // `-LiteralPath` and single quotes, because these are exactly the paths whose
806        // `%` the fallback could not survive — the command printed here has to be one
807        // that can be pasted verbatim.
808        println!(
809            "    Remove-Item -LiteralPath '{}' -Recurse -Force",
810            path.display().to_string().replace('\'', "''")
811        );
812    }
813}
814
815/// What became of one file that Windows would not let this process delete.
816#[cfg(windows)]
817enum Retired {
818    /// Gone, or queued with the session manager for the next boot.
819    Handled,
820    /// Renamed out of the way. Still on disk, but nothing resolves to it any more.
821    Aside(PathBuf),
822    /// Still sitting at the name it was installed under.
823    Stuck(PathBuf),
824}
825
826/// Finish, from inside this process, the removals Windows refused while the binary was
827/// executing -- and report, rather than defer, whatever is left.
828///
829/// Returns the paths still on disk, and whether any of them is still at the name it was
830/// installed under. That second value is the difference between an uninstall that
831/// worked and one that did not: a file renamed aside resolves to nothing, is on the
832/// sweep list, and is cleared by the next install; a file still at `devp.exe` is a
833/// working install that was asked to go away and did not.
834///
835/// Nothing here starts a child process, and that is deliberate. The obvious way to
836/// delete a running executable on Windows is to leave a shell behind to do it once you
837/// have exited: a `cmd /C` line that pings itself to pass the time and then deletes the
838/// file is the canonical form, and a hidden PowerShell running `Start-Sleep` before
839/// `Remove-Item -Recurse -Force` is the same idea in better clothes. Both are also the
840/// canonical *malware* self-delete. The literal strings are enough for a static scanner
841/// to score on, and spawning either with `CREATE_NO_WINDOW` out of an unsigned binary
842/// is enough for a behavioural one. dev-prune has been quarantined for less, so it does
843/// neither, and what is left is what Windows itself offers for a file that is in use:
844///
845/// 1. Rename the locked image aside. A running executable cannot be deleted, but it
846///    can be renamed -- the handle follows the file, not the name -- so the name is
847///    free at once and a reinstall is never blocked by the copy it replaces.
848/// 2. Ask the session manager to delete the residue at the next boot, through
849///    `MoveFileEx` with `MOVEFILE_DELAY_UNTIL_REBOOT`.
850/// 3. Report what survived both, with the command that finishes it by hand.
851#[cfg(windows)]
852fn finish_locked_removals(files: &[PathBuf], dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
853    let mut residue: Vec<PathBuf> = Vec::new();
854    let mut stuck = false;
855    for file in files {
856        match retire_file(file) {
857            Retired::Handled => {}
858            Retired::Aside(path) => residue.push(path),
859            Retired::Stuck(path) => {
860                residue.push(path);
861                stuck = true;
862            }
863        }
864    }
865    for dir in dirs {
866        stuck |= purge_tree(dir, &mut residue);
867    }
868    (residue, stuck)
869}
870
871/// The name a locked file is renamed to: `devp.exe` becomes `devp.exe.old`.
872///
873/// That name is already on the sweep list, so the residue is cleared by the next
874/// install or the next uninstall without anyone having to be told about it.
875#[cfg(windows)]
876fn aside_name(path: &Path) -> PathBuf {
877    match path.extension() {
878        Some(ext) => path.with_extension(format!("{}.old", ext.to_string_lossy())),
879        None => path.with_extension("old"),
880    }
881}
882
883/// Delete one file; failing that, get its name out of the way and queue the rest.
884#[cfg(windows)]
885fn retire_file(path: &Path) -> Retired {
886    if fs::remove_file(path).is_ok() || fs::symlink_metadata(path).is_err() {
887        return Retired::Handled;
888    }
889    let aside = aside_name(path);
890    let Ok(()) = fs::rename(path, &aside) else {
891        // The rename is the part that matters, so its failure is the real one: the
892        // install is still resolvable at this name.
893        return if delete_on_reboot(path) {
894            Retired::Handled
895        } else {
896            Retired::Stuck(path.to_path_buf())
897        };
898    };
899    if delete_on_reboot(&aside) {
900        Retired::Handled
901    } else {
902        Retired::Aside(aside)
903    }
904}
905
906/// Remove a directory tree, carrying on past the entries that are locked.
907///
908/// `fs::remove_dir_all` stops at the first error, which here is reliably the running
909/// binary -- so everything beside it would be left standing for no reason. Returns
910/// whether any file inside is still at its installed name; the directory itself
911/// surviving is residue to report, not an install that refused to go.
912#[cfg(windows)]
913fn purge_tree(dir: &Path, residue: &mut Vec<PathBuf>) -> bool {
914    if fs::remove_dir_all(dir).is_ok() || fs::symlink_metadata(dir).is_err() {
915        return false;
916    }
917    let Ok(entries) = fs::read_dir(dir) else {
918        residue.push(dir.to_path_buf());
919        return false;
920    };
921    let mut stuck = false;
922    for entry in entries.flatten() {
923        let path = entry.path();
924        // The entry's own type, never the target's: a link here is removed, not
925        // followed. A directory link needs `remove_dir` where a file link needs
926        // `remove_file`, and neither touches whatever it points at.
927        match entry.file_type() {
928            Ok(t) if t.is_symlink() => {
929                if fs::remove_file(&path).is_err() && fs::remove_dir(&path).is_err() {
930                    residue.push(path);
931                }
932            }
933            Ok(t) if t.is_dir() => stuck |= purge_tree(&path, residue),
934            _ => match retire_file(&path) {
935                Retired::Handled => {}
936                Retired::Aside(left) => residue.push(left),
937                Retired::Stuck(left) => {
938                    residue.push(left);
939                    stuck = true;
940                }
941            },
942        }
943    }
944    if fs::remove_dir(dir).is_ok() {
945        return stuck;
946    }
947    // Something inside is still open. The reboot queue is replayed in the order it was
948    // written and every file above was queued first, so this directory is empty by the
949    // time its own entry is read.
950    if !delete_on_reboot(dir) {
951        residue.push(dir.to_path_buf());
952    }
953    stuck
954}
955
956/// Queue a path with the session manager for deletion at the next boot.
957///
958/// `MoveFileEx` with a null destination and `MOVEFILE_DELAY_UNTIL_REBOOT` is what
959/// Windows offers for a file that is in use, and what an installer replacing one is
960/// expected to use. It appends to `PendingFileRenameOperations` under `HKLM`, so it
961/// needs administrator rights: from an elevated shell the residue is gone after the
962/// next restart, and from an ordinary one this fails and the caller reports the path
963/// instead. Nothing is left running either way.
964#[cfg(windows)]
965fn delete_on_reboot(path: &Path) -> bool {
966    use std::os::windows::ffi::OsStrExt;
967    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_DELAY_UNTIL_REBOOT, MoveFileExW};
968
969    let wide: Vec<u16> = path
970        .as_os_str()
971        .encode_wide()
972        .chain(std::iter::once(0))
973        .collect();
974    // SAFETY: `wide` is NUL-terminated and outlives the call, and a null destination is
975    // the documented spelling of "delete this" for this function.
976    unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }
977}
978
979/// On Unix an open file can be unlinked, so nothing is ever left locked; this exists so
980/// the call site compiles unconditionally and is unreachable in practice.
981#[cfg(not(windows))]
982fn finish_locked_removals(_files: &[PathBuf], _dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
983    (Vec::new(), false)
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    /// The sweep has to know the name the update leaves behind, or dev-prune's own
991    /// debris outlives the uninstall that reported it gone.
992    #[test]
993    #[cfg(windows)]
994    fn the_sweep_looks_for_the_file_an_update_renames_aside() {
995        let names = sweep_names();
996        assert!(names.contains(&"devp.exe.old".to_string()), "{names:?}");
997        assert!(
998            names.contains(&"dev-prune.exe.old".to_string()),
999            "{names:?}"
1000        );
1001    }
1002
1003    /// Only Windows needs the rename-aside; elsewhere the sweep is the three stems plus
1004    /// the `.new` staging name an interrupted update can leave beside any of them.
1005    #[test]
1006    #[cfg(not(windows))]
1007    fn elsewhere_the_sweep_is_the_stems_and_their_staging_names() {
1008        assert_eq!(
1009            sweep_names(),
1010            [
1011                "dev-prune",
1012                "dev-prune.new",
1013                "devp",
1014                "devp.new",
1015                "devpw",
1016                "devpw.new"
1017            ]
1018        );
1019    }
1020
1021    /// The residue has to land on a name the sweep already knows, or an uninstall that
1022    /// could not delete the running image leaves a file nothing will ever clear.
1023    #[test]
1024    #[cfg(windows)]
1025    fn the_name_a_locked_binary_is_renamed_to_is_one_the_sweep_looks_for() {
1026        let aside = aside_name(Path::new(r"C:\bin\devp.exe"));
1027        assert_eq!(aside.file_name().unwrap(), "devp.exe.old");
1028        assert!(sweep_names().contains(&"devp.exe.old".to_string()));
1029        // A name with no extension at all still gets one, rather than being left alone
1030        // under the name the install resolves to.
1031        assert_eq!(
1032            aside_name(Path::new(r"C:\bin\devp")).file_name().unwrap(),
1033            "devp.old"
1034        );
1035    }
1036
1037    /// Nothing is locked in a test, so every path here is the one that just deletes the
1038    /// file — which is the case that has to stay cheap and silent.
1039    #[test]
1040    #[cfg(windows)]
1041    fn an_unlocked_file_is_simply_deleted_and_no_residue_is_reported() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let file = dir.path().join("devp.exe");
1044        std::fs::write(&file, b"binary").unwrap();
1045
1046        let (residue, still_installed) = finish_locked_removals(std::slice::from_ref(&file), &[]);
1047
1048        assert!(residue.is_empty(), "{residue:?}");
1049        assert!(!still_installed);
1050        assert!(!file.exists());
1051        assert!(
1052            !aside_name(&file).exists(),
1053            "nothing should be renamed aside"
1054        );
1055    }
1056
1057    /// `remove_dir_all` stops at the first entry it cannot take; the tree walk must not,
1058    /// or one locked binary strands every unrelated file beside it.
1059    #[test]
1060    #[cfg(windows)]
1061    fn a_tree_with_nothing_locked_in_it_goes_completely() {
1062        let root = tempfile::tempdir().unwrap();
1063        let config = root.path().join("dev-prune");
1064        std::fs::create_dir_all(config.join("bin")).unwrap();
1065        std::fs::write(config.join("bin").join("devp.exe"), b"binary").unwrap();
1066        std::fs::write(config.join("registry.json"), b"{}").unwrap();
1067
1068        let (residue, still_installed) = finish_locked_removals(&[], std::slice::from_ref(&config));
1069
1070        assert!(residue.is_empty(), "{residue:?}");
1071        assert!(!still_installed);
1072        assert!(!config.exists());
1073    }
1074
1075    /// A directory that is already gone is the normal case after a light uninstall, and
1076    /// must not be reported as something the user has to clean up.
1077    #[test]
1078    #[cfg(windows)]
1079    fn a_path_that_is_already_gone_is_not_reported() {
1080        let root = tempfile::tempdir().unwrap();
1081        let missing = root.path().join("never-existed");
1082
1083        let (residue, still_installed) =
1084            finish_locked_removals(&[missing.join("devp.exe")], &[missing]);
1085
1086        assert!(residue.is_empty(), "{residue:?}");
1087        assert!(!still_installed);
1088    }
1089}