dev-prune 1.0.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// Handler for `dev-prune uninstall` command.
//
// Provides two uninstall modes:
// - Light (default): Removes OS background daemon, global Git hooks, and binary aliases.
//   Preserves configuration registry for seamless future re-installations.
// - Deep (`devp uninstall --deep`): Complete purge — removes daemon, hooks, binary aliases,
//   global configuration folder, and removes `.devprune.json` from registered repos.

use anyhow::Result;
use std::fs;

use crate::commands::hook;
use crate::config::Registry;
use crate::output;

pub fn run(deep: bool, yes: bool) -> Result<()> {
    output::print_header(if deep {
        "dev-prune Deep Uninstaller (Full Purge)"
    } else {
        "dev-prune Light Uninstaller"
    });

    let registry = Registry::load().ok();

    // A deep uninstall deletes files inside the user's own repositories and destroys
    // the prune history. That is not something to do on a mistyped flag.
    if deep && !yes {
        use std::io::{IsTerminal, Write};
        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
        output::print_warning(&format!(
            "This deletes the global config directory (including prune history) and \
             removes `.devprune.json` from {repo_count} registered repositories."
        ));
        if !std::io::stdin().is_terminal() {
            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
        }
        print!("Continue? [y/N]: ");
        std::io::stdout().flush()?;
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
            output::print_info("Deep uninstall cancelled.");
            return Ok(());
        }
    }

    // Steps 1 and 2 keep going when one of them fails — a scheduler that refuses to
    // uninstall must not stop the hooks being removed — but neither is silent about it.
    // Both leave the machine in a half-integrated state that the user has to know about.
    let mut left_behind: Vec<String> = Vec::new();

    // 1. Remove background daemon
    output::print_info("Removing background daemon scheduler...");
    if let Err(e) = crate::daemon::uninstall_daemon() {
        output::print_error(&format!("Background scheduler: {e:#}"));
        left_behind.push("the background scheduler".to_string());
    }

    // 2. Remove global Git hooks
    output::print_info("Removing global Git auto-registration hooks...");
    if let Err(e) = hook::run_uninstall() {
        output::print_error(&format!("Git hooks: {e:#}"));
        left_behind.push("the global Git hooks".to_string());
    }

    // 3. Remove the `devp` alias, so deleting the binary afterwards leaves nothing.
    //
    // Every invocation recreates it (`ensure_devp_alias` at the top of `run_cli`), this
    // one included — so it is back the moment dev-prune runs again. Removing it here is
    // only worth anything as the first half of "and now delete the binary", which is why
    // the binary's location is printed below rather than left for the user to find.
    if let Ok(current_exe) = std::env::current_exe() {
        if let Some(parent) = current_exe.parent() {
            #[cfg(windows)]
            let alias = parent.join("devp.exe");
            #[cfg(not(windows))]
            let alias = parent.join("devp");

            if alias.exists() && alias != current_exe {
                let _ = fs::remove_file(alias);
            }
        }
    }

    // 4. Take the `*.devprune.json` file type back out of the desktop database.
    crate::commands::icon::unregister_file_type();

    if deep {
        // Deep uninstall: remove per-repo configs & global config dir
        if let Some(reg) = registry {
            for repo_path in reg.repositories.keys() {
                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
                if cfg_file.exists() {
                    let _ = fs::remove_file(cfg_file);
                }
            }
        }

        if let Ok(config_dir) = Registry::config_dir() {
            if config_dir.exists() {
                let _ = fs::remove_dir_all(config_dir);
                output::print_info("Removed global configuration directory.");
            }
        }

        if left_behind.is_empty() {
            output::print_success(
                "Deep uninstall complete: All configurations, background daemons, and registry files purged.",
            );
        }
        // The stamp that suppresses the automatic pass lived in the directory that was
        // just deleted, so the next dev-prune command looks like a fresh install and
        // puts the hooks and the scheduler straight back. Deleting the binary is the
        // only thing that ends it, so say so instead of letting it surprise them.
        output::print_warning(
            "Running dev-prune again would reinstall the hooks and the scheduler — a \
             machine with no config directory looks like a fresh install.",
        );
        print_binary_removal_hint();
    } else {
        // Stamp the current version so the automatic pass does not reinstall, on the
        // very next command, everything this command was run to remove.
        crate::setup::suppress_next_auto_setup();
        if left_behind.is_empty() {
            output::print_success(
                "Light uninstall complete: Background daemon and Git hooks removed. Configuration preserved for future reinstall.",
            );
        }
        output::print_info("Put them back at any time with `devp setup`.");
        // Honesty about the stamp: it suppresses the pass for *this* version only.
        output::print_info(
            "The next upgrade will reinstall them. To keep them off permanently: \
             `devp config set auto_setup false`.",
        );
    }

    if !left_behind.is_empty() {
        anyhow::bail!("Uninstall finished, but {} is still installed.", {
            left_behind.join(" and ")
        });
    }

    Ok(())
}

/// Where the binary itself lives, since nothing above removes it.
fn print_binary_removal_hint() {
    let Ok(exe) = std::env::current_exe() else {
        return;
    };
    output::print_info(&format!(
        "To finish, delete the binary: {}",
        output::clean_path(&exe)
    ));
}