use std::{
borrow::Cow,
io::{self, Write as _},
process,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{help}")]
DisplayHelp {
help: String,
},
#[error("{version}")]
DisplayVersion {
version: String,
},
#[error("{schema}")]
DisplaySchema {
schema: String,
},
#[error("unknown flag `{}`", display_bytes(.token))]
UnknownFlag {
token: Vec<u8>,
},
#[error("missing value for `{name}`")]
MissingValue {
name: &'static str,
},
#[error("`{name}` does not accept a value")]
UnexpectedValue {
name: &'static str,
},
#[error("unexpected argument `{}`", display_bytes(.token))]
UnexpectedArgument {
token: Vec<u8>,
},
#[error("unknown command `{}`", display_bytes(.token))]
UnknownCommand {
token: Vec<u8>,
},
#[error("required subcommand `{name}` was not provided")]
MissingSubcommand {
name: &'static str,
},
#[error("required argument `{name}` was not provided")]
MissingRequired {
name: &'static str,
},
#[error("argument `{name}` cannot be used more than once")]
DuplicateArgument {
name: &'static str,
},
#[error("argument `{name}` is required when `{required_by}` is used")]
MissingRequirement {
name: &'static str,
required_by: &'static str,
},
#[error("argument `{name}` cannot be used with `{other}`")]
ConflictingArguments {
name: &'static str,
other: &'static str,
},
#[error("value `{}` for `{name}` is not valid UTF-8", display_bytes(.value))]
InvalidUtf8 {
name: &'static str,
value: Vec<u8>,
},
#[error(
"invalid value `{}` for `{name}`: {}",
display_bytes(.value.as_bytes()),
display_bytes(.reason.as_bytes())
)]
InvalidValue {
name: &'static str,
value: String,
reason: String,
},
}
impl Error {
#[must_use]
pub const fn exit_code(&self) -> i32 {
match self {
Self::DisplayHelp { .. } | Self::DisplayVersion { .. } | Self::DisplaySchema { .. } => {
0
}
_ => 2,
}
}
pub fn exit(&self) -> ! {
let output = self.exit_output();
match output.stream {
ExitStream::Stdout => {
let mut stdout = io::stdout().lock();
let _ = stdout.write_all(output.text.as_bytes());
let _ = stdout.flush();
}
ExitStream::Stderr => {
let mut stderr = io::stderr().lock();
let _ = stderr.write_all(output.text.as_bytes());
let _ = stderr.flush();
}
}
process::exit(output.code)
}
fn exit_output(&self) -> ExitOutput<'_> {
match self {
Self::DisplayHelp { help: text }
| Self::DisplayVersion { version: text }
| Self::DisplaySchema { schema: text } => ExitOutput {
stream: ExitStream::Stdout,
text: Cow::Borrowed(text.as_str()),
code: self.exit_code(),
},
_ => ExitOutput {
stream: ExitStream::Stderr,
text: Cow::Owned(format!("error: {self}\n\nFor more information, try '--help'.\n")),
code: self.exit_code(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExitStream {
Stdout,
Stderr,
}
struct ExitOutput<'a> {
stream: ExitStream,
text: Cow<'a, str>,
code: i32,
}
pub(crate) fn display_bytes(value: &[u8]) -> String {
String::from_utf8_lossy(value).escape_debug().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diagnostic_bytes_do_not_emit_control_characters() {
let rendered = display_bytes(b"--bad\n\x1b[31m");
assert!(!rendered.contains('\n'));
assert!(!rendered.contains('\x1b'));
assert!(rendered.contains(r"\n"));
}
#[test]
fn exit_output_uses_the_same_renderer_for_success_and_failure_policy() {
let help = Error::DisplayHelp { help: "Usage: tool [OPTIONS]\n".to_owned() };
let output = help.exit_output();
assert_eq!(output.stream, ExitStream::Stdout);
assert_eq!(output.code, 0);
assert_eq!(output.text, "Usage: tool [OPTIONS]\n");
let version = Error::DisplayVersion { version: "tool 1.2.3\n".to_owned() };
let output = version.exit_output();
assert_eq!(output.stream, ExitStream::Stdout);
assert_eq!(output.code, 0);
assert_eq!(output.text, "tool 1.2.3\n");
let schema = Error::DisplaySchema { schema: "{\"command\":{}}\n".to_owned() };
let output = schema.exit_output();
assert_eq!(output.stream, ExitStream::Stdout);
assert_eq!(output.code, 0);
assert_eq!(output.text, "{\"command\":{}}\n");
let failure = Error::UnknownFlag { token: b"--bad\nflag".to_vec() };
let output = failure.exit_output();
assert_eq!(output.stream, ExitStream::Stderr);
assert_eq!(output.code, 2);
assert_eq!(
output.text,
"error: unknown flag `--bad\\nflag`\n\nFor more information, try '--help'.\n",
);
}
#[test]
fn display_actions_use_success_status_and_render_verbatim() {
let help = Error::DisplayHelp { help: "Usage: tool [OPTIONS]\n".to_owned() };
assert_eq!(help.exit_code(), 0);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
help.to_string(),
snapbox::str![[r#"
Usage: tool [OPTIONS]
"#]],
);
let version = Error::DisplayVersion { version: "tool 1.2.3\n".to_owned() };
assert_eq!(version.exit_code(), 0);
assert_eq!(version.to_string(), "tool 1.2.3\n");
let schema = Error::DisplaySchema { schema: "{}\n".to_owned() };
assert_eq!(schema.exit_code(), 0);
assert_eq!(schema.to_string(), "{}\n");
let failure = Error::UnknownFlag { token: b"--bad".to_vec() };
assert_eq!(failure.exit_code(), 2);
}
#[test]
fn syntax_and_cardinality_errors_render_actionable_diagnostics() {
assert_eq!(
Error::UnknownFlag { token: b"--bad\nflag".to_vec() }.to_string(),
r"unknown flag `--bad\nflag`",
);
assert_eq!(
Error::MissingValue { name: "--output" }.to_string(),
"missing value for `--output`",
);
assert_eq!(
Error::UnexpectedValue { name: "--verbose" }.to_string(),
"`--verbose` does not accept a value",
);
assert_eq!(
Error::UnexpectedArgument { token: b"extra".to_vec() }.to_string(),
"unexpected argument `extra`",
);
assert_eq!(
Error::UnknownCommand { token: b"deploy".to_vec() }.to_string(),
"unknown command `deploy`",
);
assert_eq!(
Error::MissingSubcommand { name: "command" }.to_string(),
"required subcommand `command` was not provided",
);
assert_eq!(
Error::MissingRequired { name: "--output" }.to_string(),
"required argument `--output` was not provided",
);
assert_eq!(
Error::DuplicateArgument { name: "--verbose" }.to_string(),
"argument `--verbose` cannot be used more than once",
);
}
#[test]
fn relationship_errors_name_both_participating_arguments() {
assert_eq!(
Error::MissingRequirement { name: "--token", required_by: "--endpoint" }.to_string(),
"argument `--token` is required when `--endpoint` is used",
);
assert_eq!(
Error::ConflictingArguments { name: "--output", other: "--stdout" }.to_string(),
"argument `--output` cannot be used with `--stdout`",
);
}
#[test]
fn conversion_errors_escape_values_and_reasons() {
assert_eq!(
Error::InvalidUtf8 { name: "input", value: b"bad\nvalue".to_vec() }.to_string(),
r"value `bad\nvalue` for `input` is not valid UTF-8",
);
assert_eq!(
Error::InvalidValue {
name: "--port",
value: String::from("bad\nvalue"),
reason: String::from("invalid\nnumber"),
}
.to_string(),
r"invalid value `bad\nvalue` for `--port`: invalid\nnumber",
);
}
}