use std::{
borrow::Cow,
io::{self, IsTerminal as _, 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>,
usage: Option<String>,
},
#[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("the following required arguments were not provided:\n {argument}\n\nUsage: {usage}")]
MissingRequiredArguments {
argument: String,
usage: String,
},
#[error("argument `{name}` cannot be used more than once")]
DuplicateArgument {
name: &'static str,
usage: Option<String>,
},
#[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(render_diagnostic(self, diagnostic_styling_enabled())),
code: self.exit_code(),
},
}
}
}
fn diagnostic_styling_enabled() -> bool {
io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
fn render_diagnostic(error: &Error, styled: bool) -> String {
let error_label = emphasize("error:", styled, false);
let help = emphasize("--help", styled, false);
if let Error::MissingRequiredArguments { argument, usage } = error {
let usage_label = emphasize("Usage:", styled, true);
let usage = if styled { style_usage_command(usage) } else { usage.clone() };
return format!(
"{error_label} the following required arguments were not provided:\n {argument}\n\n{usage_label} {usage}\n\nFor more information, try '{help}'.\n"
);
}
let message = if styled { styled_diagnostic_message(error) } else { error.to_string() };
if let Some(usage) = structural_usage(error) {
let usage_label = emphasize("Usage:", styled, true);
let usage = if styled { style_usage_command(usage) } else { usage.to_owned() };
return format!(
"{error_label} {message}\n\n{usage_label} {usage}\n\nFor more information, try '{help}'.\n"
);
}
format!("{error_label} {message}\n\nFor more information, try '{help}'.\n")
}
fn structural_usage(error: &Error) -> Option<&str> {
match error {
Error::UnexpectedArgument { usage, .. } | Error::DuplicateArgument { usage, .. } => {
usage.as_deref()
}
_ => None,
}
}
fn styled_diagnostic_message(error: &Error) -> String {
match error {
Error::UnknownFlag { token } => {
format!("unknown flag `{}`", emphasize(&display_bytes(token), true, false))
}
Error::MissingValue { name } => {
format!("missing value for `{}`", emphasize(name, true, false))
}
Error::UnexpectedValue { name } => {
format!("`{}` does not accept a value", emphasize(name, true, false))
}
Error::UnexpectedArgument { token, .. } => {
format!("unexpected argument `{}`", emphasize(&display_bytes(token), true, false))
}
Error::UnknownCommand { token } => {
format!("unknown command `{}`", emphasize(&display_bytes(token), true, false))
}
Error::MissingSubcommand { name } => {
format!("required subcommand `{}` was not provided", emphasize(name, true, false))
}
Error::MissingRequired { name } => {
format!("required argument `{}` was not provided", emphasize(name, true, false))
}
Error::DuplicateArgument { name, .. } => {
format!("argument `{}` cannot be used more than once", emphasize(name, true, false))
}
Error::MissingRequirement { name, required_by } => format!(
"argument `{}` is required when `{}` is used",
emphasize(name, true, false),
emphasize(required_by, true, false)
),
Error::ConflictingArguments { name, other } => format!(
"argument `{}` cannot be used with `{}`",
emphasize(name, true, false),
emphasize(other, true, false)
),
Error::InvalidUtf8 { name, value } => format!(
"value `{}` for `{}` is not valid UTF-8",
emphasize(&display_bytes(value), true, false),
emphasize(name, true, false)
),
Error::InvalidValue { name, value, reason } => format!(
"invalid value `{}` for `{}`: {}",
emphasize(&display_bytes(value.as_bytes()), true, false),
emphasize(name, true, false),
display_bytes(reason.as_bytes())
),
_ => error.to_string(),
}
}
fn emphasize(value: &str, styled: bool, underline: bool) -> Cow<'_, str> {
if !styled {
return Cow::Borrowed(value);
}
let code = if underline { "1;4" } else { "1" };
Cow::Owned(format!("\x1b[{code}m{value}\x1b[0m"))
}
fn style_usage_command(usage: &str) -> String {
let boundary = usage
.find(" --")
.into_iter()
.chain(usage.find(" <"))
.chain(usage.find(" ["))
.min()
.unwrap_or(usage.len());
let (command, arguments) = usage.split_at(boundary);
format!("{}{arguments}", emphasize(command, true, false))
}
#[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(), usage: None }.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::MissingRequiredArguments {
argument: String::from("--output <OUTPUT>"),
usage: String::from("tool [OPTIONS] --output <OUTPUT>"),
}
.to_string(),
"the following required arguments were not provided:\n --output <OUTPUT>\n\nUsage: tool [OPTIONS] --output <OUTPUT>",
);
assert_eq!(
Error::DuplicateArgument { name: "--verbose", usage: None }.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 styled_diagnostics_emphasize_error_tokens_and_help_hint() {
let rendered = render_diagnostic(&Error::UnknownFlag { token: b"--wat".to_vec() }, true);
assert_eq!(
rendered,
"\x1b[1merror:\x1b[0m unknown flag `\x1b[1m--wat\x1b[0m`\n\nFor more information, try '\x1b[1m--help\x1b[0m'.\n",
);
let rendered = render_diagnostic(
&Error::InvalidValue {
name: "<VALUE>",
value: String::from("invalid"),
reason: String::from("invalid value"),
},
true,
);
assert_eq!(
rendered,
"\x1b[1merror:\x1b[0m invalid value `\x1b[1minvalid\x1b[0m` for `\x1b[1m<VALUE>\x1b[0m`: invalid value\n\nFor more information, try '\x1b[1m--help\x1b[0m'.\n",
);
}
#[test]
fn styled_missing_required_diagnostic_emphasizes_usage_structure() {
let rendered = render_diagnostic(
&Error::MissingRequiredArguments {
argument: String::from("--required <REQUIRED>"),
usage: String::from("cli command --required <REQUIRED> --optional <OPTIONAL>"),
},
true,
);
assert_eq!(
rendered,
"\x1b[1merror:\x1b[0m the following required arguments were not provided:\n --required <REQUIRED>\n\n\x1b[1;4mUsage:\x1b[0m \x1b[1mcli command\x1b[0m --required <REQUIRED> --optional <OPTIONAL>\n\nFor more information, try '\x1b[1m--help\x1b[0m'.\n",
);
}
#[test]
fn diagnostic_renderer_keeps_plain_output_free_of_terminal_controls() {
let rendered = render_diagnostic(&Error::MissingValue { name: "--limit" }, false);
assert_eq!(
rendered,
"error: missing value for `--limit`\n\nFor more information, try '--help'.\n",
);
assert!(!rendered.contains('\x1b'));
}
#[test]
fn structural_diagnostics_include_corrective_usage() {
let unexpected = Error::UnexpectedArgument {
token: b"extra".to_vec(),
usage: Some(String::from("cli get <ID>")),
};
assert_eq!(
render_diagnostic(&unexpected, false),
"error: unexpected argument `extra`\n\nUsage: cli get <ID>\n\nFor more information, try '--help'.\n",
);
let duplicate = Error::DuplicateArgument {
name: "--limit",
usage: Some(String::from("cli list [OPTIONS] <ID>")),
};
assert_eq!(
render_diagnostic(&duplicate, false),
"error: argument `--limit` cannot be used more than once\n\nUsage: cli list [OPTIONS] <ID>\n\nFor more information, try '--help'.\n",
);
}
#[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",
);
}
}