use crate::{LocalizationArgs, Localizer};
use clap::{
Command as ClapCommand,
error::{ContextKind, ContextValue, Error as ClapError, ErrorKind},
};
use std::collections::HashMap;
use std::sync::Arc;
#[must_use = "Attach this formatter when wiring clap error handling"]
pub fn clap_error_formatter(
localizer: Arc<dyn Localizer>,
) -> impl Fn(ClapError) -> ClapError + Clone {
move |error| localize_clap_error(error, localizer.as_ref())
}
#[must_use]
pub fn localize_clap_error(error: ClapError, localizer: &dyn Localizer) -> ClapError {
localize_clap_error_with_command(error, localizer, None)
}
#[must_use]
pub fn localize_clap_error_with_command(
error: ClapError,
localizer: &dyn Localizer,
command: Option<&ClapCommand>,
) -> ClapError {
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
return error;
}
let id = message_id(error.kind());
let args = localization_args(&error, command);
let args_ref = (!args.is_empty()).then_some(&args);
let rendered = error.to_string();
let mut lines = rendered.lines();
let first_line = lines.next().unwrap_or_default();
let tail = lines.collect::<Vec<_>>().join("\n");
let fallback = first_line
.strip_prefix("error: ")
.unwrap_or(first_line)
.to_owned();
let translated = localizer.message(&id, args_ref, &fallback);
if translated == fallback {
let locale = localizer
.locale()
.map_or_else(|| "unknown".to_owned(), std::string::ToString::to_string);
tracing::warn!(
identifier = %id,
error_kind = ?error.kind(),
locale = %locale,
"missing translation for clap error"
);
return error;
}
let message = if tail.is_empty() {
translated
} else {
format!("{translated}\n{tail}")
};
let _ = command; ClapError::raw(error.kind(), message)
}
fn message_id(kind: ErrorKind) -> String {
match kind {
ErrorKind::MissingRequiredArgument => "clap-error-missing-argument".to_owned(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand | ErrorKind::MissingSubcommand => {
"clap-error-missing-subcommand".to_owned()
}
ErrorKind::UnknownArgument => "clap-error-unknown-argument".to_owned(),
_ => format!("clap-error-{}", to_kebab_case(kind)),
}
}
fn localization_args(
error: &ClapError,
command: Option<&ClapCommand>,
) -> LocalizationArgs<'static> {
let mut args: LocalizationArgs<'static> = HashMap::new();
insert_context(&mut args, "argument", error.get(ContextKind::InvalidArg));
insert_context(&mut args, "value", error.get(ContextKind::InvalidValue));
insert_context(
&mut args,
"valid_values",
error.get(ContextKind::ValidValue),
);
insert_context(
&mut args,
"expected",
error.get(ContextKind::ExpectedNumValues),
);
insert_context(&mut args, "actual", error.get(ContextKind::ActualNumValues));
insert_context(&mut args, "min", error.get(ContextKind::MinValues));
insert_context(
&mut args,
"subcommand",
error.get(ContextKind::InvalidSubcommand),
);
insert_context(
&mut args,
"valid_subcommands",
error.get(ContextKind::ValidSubcommand),
);
if !args.contains_key("valid_subcommands")
&& let Some(cmd) = command
{
let names: Vec<String> = cmd
.get_subcommands()
.map(clap::Command::get_name)
.map(str::to_owned)
.collect();
if !names.is_empty() {
args.insert("valid_subcommands", names.join(", ").into());
}
}
args
}
fn insert_context(
args: &mut LocalizationArgs<'static>,
key: &'static str,
context_value: Option<&ContextValue>,
) {
let Some(actual_value) = context_value else {
return;
};
let text = stringify_context(actual_value);
if text.is_empty() {
return;
}
args.insert(key, text.into());
}
fn stringify_context(context: &ContextValue) -> String {
match context {
ContextValue::Bool(flag) => flag.to_string(),
ContextValue::String(text) => text.clone(),
ContextValue::Strings(values) => values.join(", "),
ContextValue::StyledStr(styled) => styled.to_string(),
ContextValue::StyledStrs(values) => values
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
ContextValue::Number(count) => count.to_string(),
_ => {
tracing::debug!(?context, "unhandled clap context value");
String::new()
}
}
}
fn to_kebab_case(kind: ErrorKind) -> String {
let debug = format!("{kind:?}");
let mut kebab = String::with_capacity(debug.len());
for (idx, ch) in debug.chars().enumerate() {
if ch.is_uppercase() {
if idx > 0 {
kebab.push('-');
}
kebab.push(ch.to_ascii_lowercase());
} else {
kebab.push(ch);
}
}
kebab
}