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// On Windows a running executable cannot delete itself, so whatever is still in use is
22// handed to a detached PowerShell (or `cmd.exe`) helper that waits for this process to
23// exit and then deletes it. That is scheduled work, not failure — the command
24// reports it and exits `0`.
25
26use 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    // A deep uninstall deletes files inside the user's own repositories and destroys
46    // the prune history. That is not something to do on a mistyped flag.
47    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        // stderr, like every other confirmation: with stdout piped the question would
58        // vanish into the pipe and the command would appear to hang.
59        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    // Each step keeps going when another fails — a scheduler that refuses to uninstall
70    // must not stop the hooks being removed — but none of them is silent about it. What
71    // could not be removed is reported, and its presence makes the exit code `1`.
72    let mut left_behind: Vec<String> = Vec::new();
73    // Files and directories that are in use right now (Windows keeps a running image
74    // locked, under every one of its hard-linked names). Deleted by a detached helper
75    // the moment this process exits.
76    let mut pending_files: Vec<PathBuf> = Vec::new();
77    let mut pending_dirs: Vec<PathBuf> = Vec::new();
78
79    // `DEV_PRUNE_NO_AUTO_SETUP` means "dev-prune manages nothing on this machine" — and
80    // that has to cut both ways. If the variable stopped setup from registering a
81    // scheduler or writing into agent skill directories, then uninstall must not reach
82    // for them either: whatever is there was put there by hand (or by another install
83    // this process knows nothing about), and hands-off means hands-off. It is also what
84    // lets the test suite run this command against a real machine.
85    let hands_off = setup::no_auto_setup_requested();
86
87    // 1. Background scheduler.
88    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    // 2. Global Git hooks.
102    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    // 3. The `*.devprune.json` file type, out of the desktop database.
109    crate::commands::icon::unregister_file_type();
110
111    // 4. The skill installed into AI agents' own directories. Only directories named
112    // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling — and
113    // none at all under hands-off, for the same reason as the scheduler above.
114    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    // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
139    // elsewhere. Before the binaries go, so no window exists where PATH names a
140    // directory whose contents are gone.
141    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    // 6. The binaries themselves.
153    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    // 7. Every other copy on the machine. A machine that has tried more than one
166    // install channel has more than one binary, and the ones not currently first on
167    // PATH would quietly *become* the installation the moment the managed pair above
168    // is gone.
169    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        // Per-repo configs, then the config directory itself.
182        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                    // Routine on Windows: the managed copy under `<config>/bin` is
198                    // often the very binary running this command, and a running
199                    // executable cannot be deleted. That case is finished by the
200                    // helper; anything else really is left behind.
201                    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        // Stamp the current version so a surviving copy (a package-manager install, a
217        // dev build) does not reinstall, on its very next command, everything this
218        // command was run to remove.
219        setup::suppress_next_auto_setup();
220    }
221
222    // 8. One detached helper for everything that is in use right now. PowerShell is
223    // preferred: its single-quoted string literals are fully literal, so a path
224    // carrying `%` survives, where `cmd.exe` would expand it and a `/C` command line
225    // has no way to escape one. `cmd.exe` remains the fallback for a machine without
226    // PowerShell, and whatever neither could take is listed for manual removal.
227    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
267/// Delete the managed pair, and the pair beside the running executable.
268///
269/// Skips a development build outright — deleting `target/debug/dev-prune` because a
270/// test or a contributor ran `uninstall` would destroy the build being worked on — and
271/// skips the copy beside a package-manager-owned executable, which the sweep offers to
272/// delete *with confirmation* rather than silently, because pulling files out from
273/// under a manager leaves its records dangling until its own uninstall command runs.
274fn 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        // The windowless scheduler twin, generated beside the managed binary on Windows
289        // and nowhere else. `WINDOWS_HIDDEN_BIN` already carries its `.exe`.
290        #[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(&current) {
296            output::print_info(
297                "This is a development build — leaving the `target/` binaries alone.",
298            );
299        } else if manager_owned {
300            // The caller prints the manager's own uninstall command.
301        } 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    // The managed `bin` directory should not outlive its contents. On a deep uninstall
336    // the whole config directory goes anyway; on a light one, remove it once empty, or
337    // let the helper do it after the pending deletions.
338    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
348/// One copy of dev-prune found somewhere other than the managed directory.
349struct StrayCopy {
350    path: PathBuf,
351    manager: Option<(&'static str, &'static str)>,
352}
353
354/// Find every other copy of the pair, show the list, and — with the user's yes —
355/// delete them all.
356///
357/// Discovery covers every directory on this process's PATH plus the well-known install
358/// directories that are often *not* on it any more: `~/.cargo/bin`, `~/.local/bin`
359/// (uv, pipx and the XDG convention), npm's global directory, pip's per-user `Scripts`
360/// directories, and whatever directory the running executable lives in. Only files
361/// carrying the pair's own names are ever considered, so nothing else in those
362/// directories can be touched.
363///
364/// Deletion is opt-in: the list is printed and confirmed first (`--yes` counts as
365/// confirmation; a non-terminal without it leaves everything in place). A declined
366/// prompt is a decision, not a failure — it does not change the exit code.
367fn 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    // Anything already queued for the deletion helper still exists on disk right now;
374    // finding it again here would list it as a stray and queue it twice.
375    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                // The running executable itself is often in this list. Windows keeps
418                // it locked; the detached helper finishes the job.
419                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
439/// Ask before the sweep deletes anything. `--yes` answers for the user; a pipe or a
440/// script without it gets a "no" plus the flag to pass next time.
441fn 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    // Default no, like every other deletion prompt in this tool: these files live in
451    // directories dev-prune does not manage, and a reflexive Enter should never be
452    // what deletes them. The question goes to stderr so a piped stdout cannot eat it.
453    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
464/// Every dev-prune/devp file in the sweep directories, except the managed pair (the
465/// caller already removed it), development builds, and directories, deduplicated.
466///
467/// A dangling symlink still counts — it is exactly the kind of leftover the sweep
468/// exists to clean up — which is why this checks `symlink_metadata`, not `is_file`.
469fn 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
505/// The directories worth looking in: everything on PATH, plus the install directories
506/// each supported channel writes to — which stop being on PATH the moment a venv
507/// deactivates or a profile line is removed, without the files going anywhere.
508fn 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    // Under hands-off the sweep stays inside directories the caller's own environment
514    // names. `PATH` is the caller's to shape; the home-derived extras below are this
515    // code guessing at install locations, which is exactly the reaching-around that
516    // `DEV_PRUNE_NO_AUTO_SETUP` turns off — and what keeps the test suite out of the
517    // developer's real `~/.cargo/bin`.
518    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        // `config_dir` is %APPDATA% — npm's global prefix and pip's per-user scripts
535        // both live under it.
536        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
556/// The file names one of the pair can appear under. On Windows that is more than the
557/// two `.exe`s: npm writes `.cmd` and `.ps1` shims plus an extensionless sh shim for
558/// Git Bash, and each is a separate file to delete.
559fn 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
575/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
576fn 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
586/// The on-disk file name for one of the pair, on this platform.
587fn exe_name(stem: &str) -> String {
588    if cfg!(windows) {
589        format!("{stem}.exe")
590    } else {
591        stem.to_string()
592    }
593}
594
595/// Whether a deletion failure means "in use right now" — the one case the detached
596/// helper can finish. 5 is ERROR_ACCESS_DENIED, which is what deleting the running
597/// image reports; 32 is ERROR_SHARING_VIOLATION. Anything else (read-only media, a
598/// policy block) the helper would only inherit, so it is reported instead of queued.
599fn is_in_use_error(e: &std::io::Error) -> bool {
600    matches!(e.raw_os_error(), Some(5) | Some(32))
601}
602
603/// Whether this executable is running out of a Cargo build directory.
604fn 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
609/// The package manager that owns the running executable's files, if one does, and the
610/// command that actually uninstalls through it.
611///
612/// dev-prune ships through cargo, npm, PyPI and uv as well as the installer scripts.
613/// Deleting files out from under one of those managers leaves *its* records pointing at
614/// nothing — `pip list` still shows the package, `cargo install` refuses to reinstall —
615/// so those copies are left for the manager's own uninstall, which is printed instead.
616fn 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        // npm's global shims sit *beside* its `node_modules`, not inside it.
632        if dir.join("node_modules").join("dev-prune").exists() {
633            return Some(("npm", "npm uninstall -g dev-prune"));
634        }
635        // pip puts console scripts beside a Python interpreter — the system
636        // `Scripts`/`bin` directory or a virtualenv's.
637        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
646/// The install one-liner for this platform, for the goodbye message.
647fn 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
655/// List what could not be scheduled, with enough detail to act on, plus the command
656/// that removes it.
657///
658/// The stray-copy sweep lists every path before it deletes anything; this is the same
659/// courtesy for the residue. "Some files could not be removed" leaves someone hunting
660/// through Program Files for a name they were never told, so each line carries the
661/// name, the directory it sits in, what kind of thing it is and how big it is.
662fn 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        // `-LiteralPath` and single quotes, because these are exactly the paths whose
688        // `%` the fallback could not survive — the command printed here has to be one
689        // that can be pasted verbatim.
690        println!(
691            "    Remove-Item -LiteralPath '{}' -Recurse -Force",
692            path.display().to_string().replace('\'', "''")
693        );
694    }
695}
696
697/// Quote a path as a PowerShell single-quoted string literal.
698///
699/// Inside single quotes PowerShell expands nothing at all — not `$var`, not a backtick
700/// escape, and crucially not `%VAR%`. The only character with meaning is the closing
701/// quote, and doubling it is the documented way to write a literal one. That makes this
702/// a complete escape rule for an arbitrary path, which is exactly what `cmd /C` could
703/// not offer.
704#[cfg(windows)]
705fn ps_quote(path: &Path) -> String {
706    format!("'{}'", path.display().to_string().replace('\'', "''"))
707}
708
709/// Schedule the in-use files for deletion after this process exits.
710///
711/// Returns the paths that could not be handed over, which is empty in the normal case.
712///
713/// PowerShell rather than `cmd.exe`, because `cmd` expands `%VAR%` even inside double
714/// quotes and a `/C` command line has no escape for a literal `%`. A path carrying one
715/// therefore could not be passed at all: it used to be reported and left on disk. The
716/// `cmd` route survives only as the fallback for a machine where PowerShell cannot be
717/// launched, and there the old restriction still applies.
718#[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    // Fallback. `cmd` cannot be given a literal `%`, so those paths stay behind and are
728    // returned for the caller to report.
729    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/// The PowerShell form of the retry loop. `true` if the helper was launched.
748#[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    // Three attempts, two seconds apart — the same reasoning as the `cmd` loop below.
768    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    // Windows PowerShell 5.1 ships with every supported Windows and lives at a fixed
775    // place, so it is tried by absolute path first. The rest cover the machines where
776    // it does not answer — Nano Server, an image built without the Windows PowerShell
777    // feature, or a policy that blocks the inbox copy while permitting PowerShell 7 —
778    // and those are found through `PATH`, because 7.x installs beside its own major
779    // version rather than into `System32`.
780    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/// The original `cmd.exe` form, kept as the fallback. Callers must have filtered out
803/// any path containing `%` before calling this.
804#[cfg(windows)]
805fn spawn_cmd_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
806    use std::os::windows::process::CommandExt;
807    // Not in windows-sys's prelude of imported constants anywhere else in this crate;
808    // documented value of CREATE_NO_WINDOW.
809    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
810
811    // Three attempts, two seconds apart. One would cover the normal case — this
812    // process exits the moment the command returns, releasing the image lock — but a
813    // slow exit, an antivirus scan hooked on process teardown, or the user running
814    // `devp` again inside the first window would otherwise leave the binary behind
815    // with nothing ever retrying. `cmd /C` cannot use labels, so the loop is unrolled.
816    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`, because std's quoting would wrap the whole script in quotes and
833        // `cmd /C` would then treat it as one file name rather than a command line.
834        .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/// On Unix an open file can be unlinked, so nothing ever needs scheduling; this exists
844/// so the call site compiles unconditionally and is unreachable in practice.
845#[cfg(not(windows))]
846fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> Vec<PathBuf> {
847    Vec::new()
848}