mise 2026.9.14

Dev tools, env vars, and tasks in one CLI
use std::path::PathBuf;
use std::process::ExitStatus;

use crate::cli::args::BackendArg;
use crate::file::display_path;
use crate::toolset::{ToolRequest, ToolSource, ToolVersion};
use eyre::Report;
use thiserror::Error;

#[derive(Debug, Error)]
pub(crate) enum Error {
    #[error("{0}")]
    UnsupportedTarget(String),
    #[error("[{ts}] {tr}: {source:#}")]
    FailedToResolveVersion {
        tr: Box<ToolRequest>,
        ts: ToolSource,
        source: Report,
    },
    #[error("failed to resolve required rolling channel {backend}@{version}")]
    RequiredChannelResolution {
        backend: Box<BackendArg>,
        version: String,
    },
    #[error("{tool}@{version} is not in the lockfile\nhint: {hint}")]
    NotInLockfile {
        tool: String,
        version: String,
        hint: String,
    },
    #[error("[{0}] plugin not installed")]
    PluginNotInstalled(String),
    #[error("{0}@{1} not installed")]
    VersionNotInstalled(Box<BackendArg>, String),
    #[error("{} exited with non-zero status: {}{}", .0, render_exit_status(.1), render_stderr_tail(.2))]
    ScriptFailed(String, Option<ExitStatus>, Option<String>),
    #[error("task interrupted before process start")]
    TaskInterrupted,
    #[error(
        "Config files in {} are not trusted.\nTrust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html for more information.",
        display_path(.0)
    )]
    UntrustedConfig(PathBuf),
    #[error("{}", format_install_failures(.failed_installations))]
    InstallFailed {
        successful_installations: Vec<ToolVersion>,
        failed_installations: Vec<(ToolRequest, Report)>,
    },
}

fn render_exit_status(exit_status: &Option<ExitStatus>) -> String {
    if let Some(code) = exit_status.and_then(|s| s.code()) {
        return format!("exit code {code}");
    }
    // No code means the process was signalled, and the signal is right there.
    // Reporting "no exit status" threw it away and left nothing to act on.
    #[cfg(unix)]
    if let Some(signal) = exit_status.and_then(|s| {
        use std::os::unix::process::ExitStatusExt;
        s.signal()
    }) {
        return match nix::sys::signal::Signal::try_from(signal) {
            Ok(signal) => format!("killed by {signal}"),
            Err(_) => format!("killed by signal {signal}"),
        };
    }
    "no exit status".into()
}

/// The child's own last word, appended to the bare exit status.
///
/// A command that fails during an install has already written the reason to
/// stderr — `error while loading shared libraries: libncurses.so.6` — and the
/// progress reporter prints it as it arrives. But the error that ends the run
/// carried only `exit code 127`, and under `--quiet` the live output never
/// appeared at all, so the one line that explains the failure was gone by the
/// time anyone read it.
///
/// Kept to a single line so every consumer that renders an error on one row —
/// the install summary's `✗ … · failed: …` — stays on one row.
fn render_stderr_tail(tail: &Option<String>) -> String {
    match tail {
        Some(tail) if !tail.trim().is_empty() => format!("; last stderr: {tail}"),
        _ => String::new(),
    }
}

/// When the version list could not be fetched, the version being installed
/// was never checked against it, so the install error alone can mislead.
fn version_listing_hint(tr: &ToolRequest) -> String {
    crate::backend::version_listing_failure(tr.ba())
        .map(|cause| {
            format!(
                "\nnote: {}@{} was not checked against its version list, which could not be fetched: {cause}",
                tr.ba().full(),
                tr.version()
            )
        })
        .unwrap_or_default()
}

fn format_install_failures(failed_installations: &[(ToolRequest, Report)]) -> String {
    if failed_installations.is_empty() {
        return "Installation failed".to_string();
    }

    // For a single failure, show the underlying error directly to preserve
    // the original error location for better debugging
    if failed_installations.len() == 1 {
        let (tr, error) = &failed_installations[0];
        // Show the underlying error with the tool context
        // Use {:#} to show full error chain (includes wrapped errors)
        return format!(
            "Failed to install {}@{}: {:#}{}",
            tr.ba().full(),
            tr.version(),
            error,
            version_listing_hint(tr)
        );
    }

    // For multiple failures, show a summary and then each error
    // Sort by tool name for deterministic output (parallel installs complete in arbitrary order)
    let mut sorted_failures: Vec<_> = failed_installations
        .iter()
        .map(|(tr, err)| {
            (
                format!("{}@{}", tr.ba().full(), tr.version()),
                format!("{err:#}{}", version_listing_hint(tr)),
            )
        })
        .collect();
    sorted_failures.sort_by(|a, b| a.0.cmp(&b.0));

    let mut output = vec![];
    let failed_tools: Vec<&str> = sorted_failures
        .iter()
        .map(|(name, _)| name.as_str())
        .collect();

    output.push(format!(
        "Failed to install tools: {}",
        failed_tools.join(", ")
    ));

    // Show detailed errors for each failure (in sorted order)
    // Use {:#} to show full error chain (includes wrapped errors)
    for (name, error) in sorted_failures.iter() {
        output.push(format!("\n{name}: {error}"));
    }

    output.join("\n")
}

/// Split an install result into successful versions and a result preserving any error.
pub(crate) fn split_install_result(
    result: Result<Vec<ToolVersion>, Report>,
) -> (Vec<ToolVersion>, Result<(), Report>) {
    match result {
        Ok(versions) => (versions, Ok(())),
        Err(err) => {
            let versions = match err.downcast_ref::<Error>() {
                Some(Error::InstallFailed {
                    successful_installations,
                    ..
                }) => successful_installations.clone(),
                _ => vec![],
            };
            (versions, Err(err))
        }
    }
}

impl Error {
    pub(crate) fn get_exit_status(err: &Report) -> Option<i32> {
        if let Some(Error::ScriptFailed(_, Some(status), _)) = err.downcast_ref::<Error>() {
            status.code()
        } else {
            None
        }
    }

    /// Whether the command was ended by the SIGTERM mise sends to a failed
    /// task's siblings, rather than exiting or crashing on its own.
    #[cfg(unix)]
    pub(crate) fn is_killed_by_signal(err: &Report) -> bool {
        use std::os::unix::process::ExitStatusExt;

        err.downcast_ref::<Error>().is_some_and(|err| {
            matches!(
                err,
                Error::ScriptFailed(_, Some(status), _)
                    if status.signal() == Some(nix::sys::signal::SIGTERM as i32)
            )
        })
    }

    /// Windows reports a terminated process as an ordinary exit code, so a
    /// sibling mise stopped can't be told apart from one that failed.
    #[cfg(windows)]
    pub(crate) fn is_killed_by_signal(_err: &Report) -> bool {
        true
    }

    #[cfg(unix)]
    pub(crate) fn is_sigint(err: &Report) -> bool {
        use std::os::unix::process::ExitStatusExt;

        err.downcast_ref::<Error>().is_some_and(|err| {
            matches!(
                err,
                Error::ScriptFailed(_, Some(status), _)
                    if status.signal() == Some(nix::sys::signal::SIGINT as i32)
            )
        })
    }

    /// Windows has no signals. A process ended by a console control event
    /// exits with `STATUS_CONTROL_C_EXIT`, which reports what
    /// `signal() == SIGINT` reports on Unix: the terminal interrupted this
    /// child, so its task stops without that counting as a failure.
    #[cfg(windows)]
    pub(crate) fn is_sigint(err: &Report) -> bool {
        use windows_sys::Win32::Foundation::STATUS_CONTROL_C_EXIT;

        err.downcast_ref::<Error>().is_some_and(|err| {
            matches!(
                err,
                Error::ScriptFailed(_, Some(status), _)
                    if status.code() == Some(STATUS_CONTROL_C_EXIT)
            )
        })
    }

    #[cfg(not(any(unix, windows)))]
    pub(crate) fn is_sigint(_err: &Report) -> bool {
        false
    }

    pub(crate) fn is_task_interrupted_before_start(err: &Report) -> bool {
        matches!(err.downcast_ref::<Error>(), Some(Error::TaskInterrupted))
    }

    pub(crate) fn is_argument_err(err: &Report) -> bool {
        err.downcast_ref::<Error>()
            .map(|e| {
                matches!(
                    e,
                    Error::FailedToResolveVersion {
                        ts: ToolSource::Argument,
                        ..
                    }
                )
            })
            .unwrap_or(false)
    }

    pub(crate) fn is_required_channel_resolution_err(err: &Report) -> bool {
        err.chain().any(|source| {
            matches!(
                source.downcast_ref::<Error>(),
                Some(Error::RequiredChannelResolution { .. })
            )
        })
    }

    pub(crate) fn is_not_in_lockfile(err: &Report) -> bool {
        err.chain().any(|source| {
            matches!(
                source.downcast_ref::<Error>(),
                Some(Error::NotInLockfile { .. })
            )
        })
    }
}

#[cfg(all(test, windows))]
mod windows_tests {
    use super::*;
    use std::os::windows::process::ExitStatusExt;
    use windows_sys::Win32::Foundation::STATUS_CONTROL_C_EXIT;

    #[test]
    fn detects_a_console_interrupt() {
        let status = ExitStatus::from_raw(STATUS_CONTROL_C_EXIT as u32);
        let err = Report::new(Error::ScriptFailed("cmd".into(), Some(status), None));

        assert!(Error::is_sigint(&err));
    }

    #[test]
    fn does_not_treat_an_ordinary_failure_as_an_interrupt() {
        let status = ExitStatus::from_raw(1);
        let err = Report::new(Error::ScriptFailed("cmd".into(), Some(status), None));

        assert!(!Error::is_sigint(&err));
    }
}

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use std::os::unix::process::ExitStatusExt;

    #[test]
    fn detects_sigint_script_failure() {
        let status = ExitStatus::from_raw(nix::sys::signal::SIGINT as i32);
        let err = Report::new(Error::ScriptFailed("sh".into(), Some(status), None));

        assert!(Error::is_sigint(&err));
    }

    #[test]
    fn does_not_treat_exit_code_as_sigint() {
        let status = ExitStatus::from_raw(2 << 8);
        let err = Report::new(Error::ScriptFailed("sh".into(), Some(status), None));

        assert!(!Error::is_sigint(&err));
    }

    #[test]
    fn renders_the_signal_that_killed_the_process() {
        // "no exit status" threw away the one fact that explains the failure.
        let status = ExitStatus::from_raw(nix::sys::signal::SIGINT as i32);
        assert_eq!(render_exit_status(&Some(status)), "killed by SIGINT");

        let status = ExitStatus::from_raw(nix::sys::signal::SIGTERM as i32);
        assert_eq!(render_exit_status(&Some(status)), "killed by SIGTERM");
    }

    #[test]
    fn renders_an_exit_code_unchanged() {
        let status = ExitStatus::from_raw(2 << 8);
        assert_eq!(render_exit_status(&Some(status)), "exit code 2");
        assert_eq!(render_exit_status(&None), "no exit status");
    }

    #[test]
    fn detects_interruption_before_process_start() {
        let err = Report::new(Error::TaskInterrupted);

        assert!(Error::is_task_interrupted_before_start(&err));
    }

    #[test]
    fn detects_not_in_lockfile() {
        let err = Report::new(Error::NotInLockfile {
            tool: "usage".into(),
            version: "latest".into(),
            hint: "Run `mise install` without --locked to update the lockfile".into(),
        });

        assert!(Error::is_not_in_lockfile(&err));
        assert!(!Error::is_required_channel_resolution_err(&err));
    }
}