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());
}
}