Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

holger-cli/src/error.rs

//! **What can go wrong, and the code each one says** (`holger-cli-1..4`).
//!
//! The client decides nothing about the server's answers: a refusal the server sent is
//! shown as it came, with the server's own code (`holger-<subsystem>-<n>`). The client's
//! own four codes are only for what happens before or instead of an answer.

use std::fmt;
use std::path::PathBuf;

use holger_errcode::{
    CLI_INVALID_ARGS, CLI_PATH_MISSING, CLI_SERVER_UNREACHABLE, CLI_VERB_UNAVAILABLE,
};

/// One failure of one command.
#[derive(Debug)]
pub enum CliError {
    /// The command line is not a command (`holger-cli-1`).
    InvalidArgs(String),
    /// A file the command reads is not there (`holger-cli-2`).
    PathMissing(PathBuf),
    /// The server does not carry this verb in its build (`holger-cli-3`), as it said
    /// with `UNIMPLEMENTED`.
    VerbUnavailable(String),
    /// The server at `--at` was not reached (`holger-cli-4`).
    Unreachable(String),
    /// The server answered with a refusal: shown as it came, its code included.
    Server(tonic::Status),
    /// A local step failed (writing an output file, the credential store).
    Local(String),
}

impl CliError {
    /// The process exit status for this failure: 2 for a command line that is not a
    /// command (as clap does), 1 for everything else.
    pub fn exit_code(&self) -> i32 {
        match self {
            CliError::InvalidArgs(_) => 2,
            _ => 1,
        }
    }

    /// The `holger-*` code this failure carries, if any: the client's own, or the one the
    /// server put on the wire.
    pub fn code(&self) -> Option<String> {
        match self {
            CliError::InvalidArgs(_) => Some(CLI_INVALID_ARGS.code.to_owned()),
            CliError::PathMissing(_) => Some(CLI_PATH_MISSING.code.to_owned()),
            CliError::VerbUnavailable(_) => Some(CLI_VERB_UNAVAILABLE.code.to_owned()),
            CliError::Unreachable(_) => Some(CLI_SERVER_UNREACHABLE.code.to_owned()),
            CliError::Server(s) => s
                .metadata()
                .get(holger_errcode::WIRE_CODE_KEY)
                .and_then(|v| v.to_str().ok())
                .map(str::to_owned),
            CliError::Local(_) => None,
        }
    }

    /// Pure: a gRPC answer as a failure. `UNIMPLEMENTED` is a verb this server's build
    /// does not carry (`holger-cli-3`), and a transport failure is a server not reached
    /// (`holger-cli-4`); every other refusal is the server's own, kept whole.
    pub fn from_status(verb: &str, s: tonic::Status) -> CliError {
        match s.code() {
            tonic::Code::Unimplemented => {
                CliError::VerbUnavailable(format!("{verb}: {}", s.message()))
            }
            tonic::Code::Unavailable
                if s.metadata().get(holger_errcode::WIRE_CODE_KEY).is_none() =>
            {
                CliError::Unreachable(format!("{verb}: {}", s.message()))
            }
            _ => CliError::Server(s),
        }
    }
}

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CliError::InvalidArgs(m) => f.write_str(&CLI_INVALID_ARGS.tag(m)),
            CliError::PathMissing(p) => {
                f.write_str(&CLI_PATH_MISSING.tag(format_args!("{} does not exist", p.display())))
            }
            CliError::VerbUnavailable(m) => f.write_str(
                &CLI_VERB_UNAVAILABLE
                    .tag(format_args!("{m} (this server's build does not carry it)")),
            ),
            CliError::Unreachable(m) => f.write_str(&CLI_SERVER_UNREACHABLE.tag(m)),
            CliError::Server(s) => write!(f, "{} ({:?})", s.message(), s.code()),
            CliError::Local(m) => f.write_str(m),
        }
    }
}

impl std::error::Error for CliError {}

/// A file the command reads, or [`CliError::PathMissing`].
pub fn existing(path: &std::path::Path) -> Result<PathBuf, CliError> {
    if path.exists() {
        Ok(path.to_path_buf())
    } else {
        Err(CliError::PathMissing(path.to_path_buf()))
    }
}

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

    #[test]
    fn each_of_the_clients_failures_says_its_own_code() {
        let cases = [
            (CliError::InvalidArgs("x".into()), "holger-cli-1"),
            (CliError::PathMissing("/nope".into()), "holger-cli-2"),
            (CliError::VerbUnavailable("x".into()), "holger-cli-3"),
            (CliError::Unreachable("x".into()), "holger-cli-4"),
        ];
        for (e, code) in cases {
            assert_eq!(e.code().as_deref(), Some(code));
            assert!(e.to_string().starts_with(&format!("[{code}] ")), "{e}");
        }
        assert_eq!(CliError::InvalidArgs("x".into()).exit_code(), 2);
        assert_eq!(CliError::Unreachable("x".into()).exit_code(), 1);
    }

    #[test]
    fn unimplemented_is_a_verb_this_build_lacks_and_a_coded_refusal_stays_the_servers() {
        assert!(matches!(
            CliError::from_status("migration discover", tonic::Status::unimplemented("no")),
            CliError::VerbUnavailable(_)
        ));
        assert!(matches!(
            CliError::from_status("health", tonic::Status::unavailable("tcp connect error")),
            CliError::Unreachable(_)
        ));
        let mut coded = tonic::Status::unavailable("[holger-grow-1] held");
        coded.metadata_mut().insert(
            holger_errcode::WIRE_CODE_KEY,
            "holger-grow-1".parse().unwrap(),
        );
        let e = CliError::from_status("put", coded);
        assert_eq!(
            e.code().as_deref(),
            Some("holger-grow-1"),
            "the server's code, not ours"
        );
        assert!(matches!(
            CliError::from_status("x", tonic::Status::permission_denied("no")),
            CliError::Server(_)
        ));
    }

    #[test]
    fn a_missing_input_file_is_named() {
        let e = existing(std::path::Path::new("/does/not/exist.bin")).unwrap_err();
        assert!(e.to_string().contains("/does/not/exist.bin"), "{e}");
        assert!(existing(std::path::Path::new("/")).is_ok());
    }
}