pmpx 0.3.2

Detects a project and routes package manager commands to the matching tool.
//! Error types and exit codes.
//!
//! Exit codes are an interface user scripts can rely on (`pmpx test || echo failed` must
//! work), so they are part of the error type instead of being guessed from a matched string
//! in `main`.
//!
//! The only exception is a backend process exit code, which is passed through verbatim:
//! that is not an error path but a normal outcome, so it travels as `Ok(code)`.

/// pmpx's success/failure result.
pub type Result<T> = std::result::Result<T, PmpxError>;

/// Exit code 0: success.
pub const EXIT_OK: u8 = 0;
/// Exit code 1: a pmpx error of its own (config parse failure, file lock timeout, plugin
/// removal conflict, and so on).
pub const EXIT_INTERNAL: u8 = 1;
/// Exit code 2: usage error / unsupported verb.
pub const EXIT_USAGE: u8 = 2;
/// Exit code 3: no project type detected / no plugin can handle the family / backend
/// executable not found.
pub const EXIT_NOT_FOUND: u8 = 3;

/// A pmpx error.
///
/// Each variant maps to one class of exit code produced by pmpx itself; a backend exit code
/// is not an error, see the module docs.
#[derive(Debug, thiserror::Error)]
pub enum PmpxError {
    /// Usage error, or the plugin explicitly does not support this verb.
    ///
    /// "Unsupported" belongs here too: the verb the user typed is valid, this backend cannot
    /// do it.
    #[error("{0}")]
    Usage(String),

    /// No project type detected / no plugin can handle the family / backend executable not
    /// found.
    ///
    /// The common thread is that pmpx itself is fine and the environment is missing
    /// something; the message carries what to do next.
    #[error("{0}")]
    NotFound(String),

    /// pmpx itself failed. This is the catch-all; normally it should not be reached.
    #[error(transparent)]
    Other(#[from] anyhow::Error),

    /// A failure reported by the **backend**; the exit code is the backend's, not pmpx's.
    ///
    /// It cannot be folded into [`PmpxError::Usage`]: then "the plugin does not support this
    /// verb" and "the plugin blew up" would be indistinguishable, and scripts need to handle
    /// them separately.
    #[error("{0}")]
    Backend(String, u8),
}

impl PmpxError {
    /// Which code this error should make the process exit with.
    pub fn exit_code(&self) -> u8 {
        match self {
            PmpxError::Usage(_) => EXIT_USAGE,
            PmpxError::NotFound(_) => EXIT_NOT_FOUND,
            PmpxError::Other(_) => EXIT_INTERNAL,
            PmpxError::Backend(_, code) => *code,
        }
    }

    /// Build an "the environment is missing something" error.
    pub fn not_found(message: impl Into<String>) -> Self {
        PmpxError::NotFound(message.into())
    }
}

// ---- How pmpx's own words reach stderr ------------------------------------

/// One `pmpx: <message>` line on stderr, in the error style.
///
/// The one place that knows the shape of pmpx's own messages: an error on the way out of `main`,
/// a failed plugin load, a crates.io lookup that did not answer. A script scanning for `pmpx:` and
/// a person reading the terminal then see the same thing from every command.
pub fn error_line(body: impl std::fmt::Display) {
    anstream::eprintln!(
        "{} {}",
        crate::style::paint(crate::style::ERROR, "pmpx:"),
        crate::style::paint(crate::style::ERROR_BODY, body)
    );
}

/// The same `pmpx:` prefix, dimmed: something worth saying that is **not** a failure.
///
/// A backend's unusual exit code belongs here rather than in [`error_line`]: pmpx is passing it
/// through as it promised, and marking that with the error style would make a script -- or a
/// reader -- treat a working run as a broken one.
pub fn note_line(body: impl std::fmt::Display) {
    anstream::eprintln!(
        "{}",
        crate::style::paint(crate::style::DIM, format!("pmpx: {body}"))
    );
}

/// The engine reports what happened; the *exit code* is this program's decision, so the mapping lives
/// here rather than in the engine.
impl From<pmpx_engine::EngineError> for PmpxError {
    fn from(error: pmpx_engine::EngineError) -> Self {
        use pmpx_engine::EngineError;

        match error {
            // The user asked for something impossible.
            EngineError::Usage(message) => PmpxError::Usage(message),

            // Everything that means "your setup is incomplete": exit code 3, the code a script reads to
            // tell an incomplete install from a broken pmpx.
            EngineError::NotFound(message) | EngineError::Setup(message) => {
                PmpxError::NotFound(message)
            }
            EngineError::NoProject(message) => PmpxError::NotFound(message),
            EngineError::Detect(failure) => PmpxError::NotFound(failure.message()),

            // The plugin store refused: a network failure, a build that would not compile, a crate that
            // is not there. The kit's message says which, and pmpx has nothing to add -- so this is
            // pmpx's own failure, not the user's usage.
            EngineError::Kit(error) => PmpxError::Other(anyhow::Error::new(error)),

            // The plugin answered normally that it cannot do this: a usage error if that is what it said,
            // and pmpx's own failure if it broke.
            EngineError::Call {
                plugin,
                verb,
                error,
            } => PmpxError::Backend(
                format!("plugin {plugin} cannot do `{verb}`: {error}"),
                call_exit_code(&error),
            ),

            EngineError::Start { .. } => PmpxError::Other(anyhow::anyhow!(error)),
        }
    }
}

/// The exit code for a plugin that answered "I cannot".
fn call_exit_code(error: &pmpx_loader::CallError) -> u8 {
    match error {
        pmpx_loader::CallError::UnsupportedVerb
        | pmpx_loader::CallError::InvalidArgs
        | pmpx_loader::CallError::ShortCommand { .. } => EXIT_USAGE,
        pmpx_loader::CallError::Internal | pmpx_loader::CallError::Unknown(_) => EXIT_INTERNAL,
    }
}