arcbox-cli 0.6.9

Command-line interface for ArcBox
//! User-facing CLI error context.

use std::fmt;

use anyhow::Error;
use connectrpc::{ConnectError, ErrorCode};

#[derive(Debug)]
struct ActionableError {
    message: String,
    source: ConnectError,
}

impl fmt::Display for ActionableError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for ActionableError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

pub fn machine_request(error: ConnectError, name: &str, operation: &str) -> Error {
    let message = match error.code {
        ErrorCode::NotFound => {
            format!("Machine '{name}' was not found. List machines with `abctl machine list`.")
        }
        ErrorCode::FailedPrecondition => format!(
            "Machine '{name}' must be running for {operation}. Start it with \
             `abctl machine start {name}`."
        ),
        ErrorCode::Unavailable => format!(
            "Machine '{name}' is unavailable during {operation}. Retry the command; if the \
             problem persists, check `abctl daemon status`."
        ),
        _ => format!(
            "Could not perform {operation} for machine '{name}'. Re-run with --debug for details."
        ),
    };
    actionable(message, error)
}

pub fn machine_operation(error: ConnectError, name: &str, action: &str) -> Error {
    let message = match error.code {
        ErrorCode::NotFound => {
            format!("Machine '{name}' was not found. List machines with `abctl machine list`.")
        }
        ErrorCode::FailedPrecondition => format!(
            "Machine '{name}' is not in a valid state for this operation. Inspect it with \
             `abctl machine inspect {name}`."
        ),
        ErrorCode::Unavailable => format!(
            "Machine '{name}' is unavailable. Retry the command; if the problem persists, \
             check `abctl daemon status`."
        ),
        _ => format!("Could not {action} machine '{name}'. Re-run with --debug for details."),
    };
    actionable(message, error)
}

pub fn sandbox_request(error: ConnectError, id: &str, operation: &str) -> Error {
    let message = match error.code {
        ErrorCode::NotFound => {
            format!("Sandbox '{id}' was not found. List sandboxes with `abctl sandbox list`.")
        }
        ErrorCode::FailedPrecondition => format!(
            "Sandbox '{id}' is not ready for {operation}. Inspect it with \
             `abctl sandbox inspect {id}`."
        ),
        ErrorCode::Unavailable => format!(
            "Sandbox '{id}' is unavailable during {operation}. Retry the command; if the \
             problem persists, check `abctl daemon status`."
        ),
        _ => format!(
            "Could not perform {operation} for sandbox '{id}'. Re-run with --debug for details."
        ),
    };
    actionable(message, error)
}

pub fn snapshot_request(error: ConnectError, id: &str, operation: &str) -> Error {
    let message = match error.code {
        ErrorCode::NotFound => {
            format!("Snapshot '{id}' was not found. List snapshots with `abctl sandbox snapshots`.")
        }
        ErrorCode::FailedPrecondition => format!(
            "Snapshot '{id}' is not in a valid state for {operation}. List snapshots with \
             `abctl sandbox snapshots`."
        ),
        ErrorCode::Unavailable => format!(
            "Snapshot '{id}' is unavailable during {operation}. Retry the command; if the \
             problem persists, check `abctl daemon status`."
        ),
        _ => format!(
            "Could not perform {operation} for snapshot '{id}'. Re-run with --debug for details."
        ),
    };
    actionable(message, error)
}

pub fn execution_wait(error: ConnectError, sandbox_id: &str, execution_id: &str) -> Error {
    let message = match error.code {
        ErrorCode::NotFound => {
            format!("Execution '{execution_id}' was not found in sandbox '{sandbox_id}'.")
        }
        ErrorCode::Unavailable => format!(
            "Sandbox '{sandbox_id}' is unavailable while waiting for execution \
             '{execution_id}'. Retry the command; if the problem persists, check \
             `abctl daemon status`."
        ),
        _ => format!(
            "Could not determine the exit status of execution '{execution_id}' in sandbox \
             '{sandbox_id}'. Re-run with --debug for details."
        ),
    };
    actionable(message, error)
}

pub fn machine_exec_output(error: ConnectError, name: &str, command: &str) -> Error {
    let message = if connection_lost(&error) {
        format!("Connection to machine '{name}' was lost while running '{command}'.")
    } else {
        match error.code {
            ErrorCode::NotFound => {
                format!("Command '{command}' was not found in machine '{name}'.")
            }
            _ => format!(
                "Could not read output from '{command}' in machine '{name}'. Re-run with --debug \
                 for details."
            ),
        }
    };
    actionable(message, error)
}

fn actionable(message: String, source: ConnectError) -> Error {
    Error::new(ActionableError { message, source })
}

fn connection_lost(error: &ConnectError) -> bool {
    if error.code == ErrorCode::Unavailable {
        return true;
    }
    // connectrpc reports body-read and missing-END_STREAM failures as Internal
    // with these messages — it exposes no distinct code to match on, so the
    // message text is the only signal, and it is not a stability contract.
    //
    // The body-read wording moved between revisions: "error reading response
    // body" became "failed to read response body" when the three body-read
    // sites were centralised on one helper. Both are matched because a miss
    // here is silent — a lost connection degrades to the generic "could not
    // read output" message with nothing failing to say so.
    error.code == ErrorCode::Internal
        && error.message.as_deref().is_some_and(|message| {
            message.starts_with("error reading response body:")
                || message.starts_with("failed to read response body")
                || message == "Connect streaming response ended without END_STREAM envelope"
        })
}

pub fn render(error: &Error, debug: bool) -> String {
    if debug {
        format!("Error: {error:?}")
    } else if let Some(actionable) = error
        .chain()
        .find_map(|cause| cause.downcast_ref::<ActionableError>())
    {
        format!("Error: {actionable}")
    } else {
        format!("Error: {error:?}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resource_errors_are_actionable_and_keep_debug_context() {
        let stopped = machine_request(
            ConnectError::failed_precondition("invalid state: CID not assigned"),
            "dev",
            "ping",
        );
        assert_eq!(
            render(&stopped, false),
            "Error: Machine 'dev' must be running for ping. Start it with \
             `abctl machine start dev`."
        );
        assert!(render(&stopped, true).contains("invalid state: CID not assigned"));

        let missing = sandbox_request(
            ConnectError::not_found("core error: VM not found"),
            "gone",
            "inspection",
        );
        assert_eq!(
            render(&missing, false),
            "Error: Sandbox 'gone' was not found. List sandboxes with \
             `abctl sandbox list`."
        );
        assert!(render(&missing, true).contains("core error: VM not found"));

        let already_running = machine_operation(
            ConnectError::failed_precondition("machine already running"),
            "dev",
            "start",
        );
        assert_eq!(
            render(&already_running, false),
            "Error: Machine 'dev' is not in a valid state for this operation. Inspect it with \
             `abctl machine inspect dev`."
        );
        assert!(render(&already_running, true).contains("machine already running"));

        let missing_execution = execution_wait(
            ConnectError::not_found("execution not found"),
            "sandbox-1",
            "exec-2",
        );
        assert_eq!(
            render(&missing_execution, false),
            "Error: Execution 'exec-2' was not found in sandbox 'sandbox-1'."
        );

        let unmapped = Error::msg("VM not found").context("Failed to inspect another resource");
        let rendered = render(&unmapped, false);
        assert!(rendered.contains("Failed to inspect another resource"));
        assert!(rendered.contains("VM not found"));
    }

    #[test]
    fn exec_errors_distinguish_command_transport_and_output_failures() {
        let missing = machine_exec_output(
            ConnectError::not_found("command not found: nope"),
            "dev",
            "nope",
        );
        assert_eq!(
            missing.to_string(),
            "Command 'nope' was not found in machine 'dev'."
        );

        let transport = machine_exec_output(
            ConnectError::unavailable("h2 connection closed"),
            "dev",
            "date",
        );
        assert_eq!(
            transport.to_string(),
            "Connection to machine 'dev' was lost while running 'date'."
        );

        let truncated = machine_exec_output(
            ConnectError::internal("Connect streaming response ended without END_STREAM envelope"),
            "dev",
            "date",
        );
        assert_eq!(
            truncated.to_string(),
            "Connection to machine 'dev' was lost while running 'date'."
        );

        // Both body-read wordings connectrpc has used. Only the message text
        // distinguishes these from an output failure, so a wording drift here
        // is invisible without a case per spelling.
        for message in [
            "error reading response body: connection reset",
            "failed to read response body: connection reset",
        ] {
            let body_read = machine_exec_output(ConnectError::internal(message), "dev", "date");
            assert_eq!(
                body_read.to_string(),
                "Connection to machine 'dev' was lost while running 'date'.",
                "body-read failure was not recognised as a lost connection: {message}"
            );
        }

        let output = machine_exec_output(
            ConnectError::internal("failed to decode response"),
            "dev",
            "date",
        );
        assert_eq!(
            output.to_string(),
            "Could not read output from 'date' in machine 'dev'. Re-run with --debug for details."
        );
    }

    #[test]
    fn unclassified_errors_point_to_debug_details() {
        let errors = [
            machine_request(ConnectError::internal("raw"), "dev", "ping"),
            machine_operation(ConnectError::internal("raw"), "dev", "start"),
            sandbox_request(ConnectError::internal("raw"), "box", "inspection"),
            snapshot_request(ConnectError::internal("raw"), "snap", "restore"),
            execution_wait(ConnectError::internal("raw"), "box", "exec"),
            machine_exec_output(ConnectError::internal("raw"), "dev", "date"),
        ];
        let expected = [
            "Error: Could not perform ping for machine 'dev'. Re-run with --debug for details.",
            "Error: Could not start machine 'dev'. Re-run with --debug for details.",
            "Error: Could not perform inspection for sandbox 'box'. Re-run with --debug for details.",
            "Error: Could not perform restore for snapshot 'snap'. Re-run with --debug for details.",
            "Error: Could not determine the exit status of execution 'exec' in sandbox 'box'. Re-run with --debug for details.",
            "Error: Could not read output from 'date' in machine 'dev'. Re-run with --debug for details.",
        ];

        for (error, expected) in errors.iter().zip(expected) {
            assert_eq!(render(error, false), expected);
        }
    }
}