mise 2026.9.3

Dev tools, env vars, and tasks in one CLI
use std::sync::Arc;

use console::style;
use eyre::{Result, bail, eyre};
use itertools::Itertools;

use crate::backend::Backend;
use crate::cli::args::ToolArg;
use crate::config::Config;
use crate::toolset::{ToolRequest, ToolSource, ToolVersion, ToolsetBuilder};
use crate::ui::install_progress::removal_progress;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::{config, dirs, exit, file};

/// Remove installed tool versions
///
/// This only removes the installed version; it does not modify mise.toml.
/// Use `mise unuse` to remove a tool from mise.toml and uninstall it.
#[derive(Debug, usage_rs::Args)]
#[usage(
    verbatim_doc_comment,
    example(
        r###"mise uninstall node@18.0.0"###,
        help = r###"uninstall a specific version"###
    ),
    example(
        r###"mise uninstall node"###,
        help = r###"uninstall the current node version (if only one version is installed)"###
    ),
    example(
        r###"mise uninstall --all node"###,
        help = r###"uninstall every installed version of node"###
    )
)]
pub(crate) struct Uninstall {
    /// Tool(s) to remove
    #[usage(value_name = "INSTALLED_TOOL@VERSION", required_unless = "all")]
    installed_tool: Vec<ToolArg>,

    /// Delete all installed versions
    #[usage(long, short)]
    all: bool,

    /// Do not actually delete anything
    #[usage(long, short = 'n')]
    dry_run: bool,

    /// Like --dry-run but exits with code 1 if there are tools to uninstall
    ///
    /// This is useful for scripts to check if tools need to be uninstalled.
    #[usage(long, verbatim_doc_comment)]
    dry_run_code: bool,
}

impl Uninstall {
    pub(super) fn is_dry_run(&self) -> bool {
        self.dry_run || self.dry_run_code
    }

    pub(crate) async fn run(self) -> Result<()> {
        let config = Config::get().await?;
        let tool_versions = if self.installed_tool.is_empty() && self.all {
            self.get_all_tool_versions(&config).await?
        } else {
            self.get_requested_tool_versions(&config).await?
        };
        let tool_versions = tool_versions
            .into_iter()
            .unique_by(|(_, tv)| (tv.request.ba().short.clone(), tv.version.clone()))
            .collect::<Vec<_>>();
        if !self.all && tool_versions.len() > self.installed_tool.len() {
            bail!("multiple tools specified, use --all to uninstall all versions");
        }
        let removed_install_paths = tool_versions
            .iter()
            .map(|(_, tv)| tv.install_path())
            .collect::<Vec<_>>();

        let mpr = MultiProgressReport::get();
        let mut to_remove = Vec::with_capacity(tool_versions.len());
        for (plugin, tv) in tool_versions {
            // `is_version_installed` resolves the install path, so it says no for a link whose
            // target is gone -- and that entry is precisely what someone running `uninstall` is
            // trying to get rid of. Refusing it left the name occupied with no command able to
            // free it. It is still not *installed*: this only decides whether there is something
            // here to remove.
            if !plugin.is_version_installed(&config, &tv, true)
                && !file::entry_exists(tv.install_path())
            {
                warn!("{} is not installed", tv.style());
                continue;
            }
            to_remove.push((plugin, tv));
        }
        let has_work = !to_remove.is_empty();

        // A dry run keeps its per-line report: it exists to be read.
        let mut progress = if self.is_dry_run() {
            None
        } else {
            removal_progress(
                &mpr,
                to_remove
                    .iter()
                    .map(|(_, tv)| (format!("{}@{}", tv.ba().short, tv.version), tv.style())),
            )
        };
        for (plugin, tv) in to_remove {
            let key = format!("{}@{}", tv.ba().short, tv.version);
            let tool = progress
                .as_ref()
                .and_then(|progress| progress.start_tool(&key));
            let pr = match &tool {
                Some(tool) => tool.reporter(),
                None => mpr.add(&tv.style()),
            };
            let result = plugin
                .uninstall_version(&config, &tv, pr.as_ref(), self.is_dry_run())
                .await;
            if let Some(tool) = &tool {
                tool.complete(result.as_ref().err().map(|e| e.to_string()).as_deref());
            }
            if let Err(err) = result {
                error!("{err}");
                return Err(eyre!(err).wrap_err(format!("failed to uninstall {tv}")));
            }
            if self.is_dry_run() {
                pr.finish_with_message("uninstalled (dry-run)".into());
            } else {
                if let Err(err) = crate::tool_purgatory::forget_path(&tv.install_path()) {
                    warn!("failed to clear tool purgatory entry: {err:#}");
                }
                if tool.is_none() {
                    pr.finish_with_message("uninstalled".into());
                }
            }
        }
        if let Some(progress) = progress.as_mut() {
            progress.finish(vec![]);
        }

        if self.is_dry_run() {
            if self.dry_run_code && has_work {
                return Err(exit::request(1));
            }
            return Ok(());
        }

        file::touch_dir(&dirs::DATA)?;
        let config = Config::reset().await?;
        let ts = config.get_toolset().await?;
        config::rebuild_shims_and_runtime_symlinks_after_removal(
            &config,
            ts,
            &removed_install_paths,
        )
        .await?;

        Ok(())
    }

    async fn get_all_tool_versions(
        &self,
        config: &Arc<Config>,
    ) -> Result<Vec<(Arc<dyn Backend>, ToolVersion)>> {
        let ts = ToolsetBuilder::new().build(config).await?;
        let tool_versions = ts
            .list_installed_versions(config)
            .await?
            .into_iter()
            .collect::<Vec<_>>();
        Ok(tool_versions)
    }
    async fn get_requested_tool_versions(
        &self,
        config: &Arc<Config>,
    ) -> Result<Vec<(Arc<dyn Backend>, ToolVersion)>> {
        let runtimes = ToolArg::double_tool_condition(&self.installed_tool)?;
        let mut tool_versions = Vec::new();
        for ta in runtimes {
            let backend = ta.ba.backend()?;
            let query = ta.tvr.as_ref().map(|tvr| tvr.version()).unwrap_or_default();
            let installed_versions = backend.list_installed_versions();
            let exact_match = installed_versions.iter().find(|v| v == &&query);
            let matches = match exact_match {
                Some(m) => vec![m],
                None => installed_versions
                    .iter()
                    .filter(|v| v.starts_with(&query))
                    .collect_vec(),
            };

            let mut tvs = Vec::new();

            if let Some(tvr) = &ta.tvr {
                tvs.push((
                    backend.clone(),
                    tvr.resolve(config, &Default::default()).await?,
                ));
            }

            tvs.extend(
                matches
                    .into_iter()
                    .map(|v| {
                        let tvr = ToolRequest::new(backend.ba().clone(), v, ToolSource::Unknown)?;
                        let tv = ToolVersion::new(tvr, v.into());
                        Ok((backend.clone(), tv))
                    })
                    .collect::<Result<Vec<_>>>()?,
            );

            if tvs.is_empty() {
                warn!(
                    "no versions found for {}",
                    style(&backend).blue().for_stderr()
                );
            }
            tool_versions.extend(tvs);
        }
        Ok(tool_versions)
    }
}