use serde_json::json;
use crate::bip39_friendly::friendly_bip39;
use crate::codex32_friendly::friendly_codex32;
#[derive(Debug)]
#[non_exhaustive]
pub enum CliError {
BadInput(String),
Bip39(bip39::Error),
Codex32(codex32::Error),
UnexpectedStringLength { got: usize },
PayloadLengthMismatch { got: usize, tag: [u8; 4] },
FormatViolation {
underlying_kind: &'static str,
message: String,
details: Option<serde_json::Value>,
},
FutureFormat { tag: [u8; 4] },
VerifyPhraseMismatch,
}
impl CliError {
pub fn exit_code(&self) -> u8 {
match self {
CliError::BadInput(_)
| CliError::Bip39(_)
| CliError::Codex32(_)
| CliError::UnexpectedStringLength { .. }
| CliError::PayloadLengthMismatch { .. } => 1,
CliError::FormatViolation { .. } => 2,
CliError::FutureFormat { .. } => 3,
CliError::VerifyPhraseMismatch => 4,
}
}
pub fn kind(&self) -> &'static str {
match self {
CliError::BadInput(_) => "BadInput",
CliError::Bip39(_) => "Bip39",
CliError::Codex32(_) => "Codex32",
CliError::UnexpectedStringLength { .. } => "UnexpectedStringLength",
CliError::PayloadLengthMismatch { .. } => "PayloadLengthMismatch",
CliError::FormatViolation {
underlying_kind, ..
} => underlying_kind,
CliError::FutureFormat { .. } => "FutureFormat",
CliError::VerifyPhraseMismatch => "VerifyPhraseMismatch",
}
}
pub fn message(&self) -> String {
match self {
CliError::BadInput(m) => m.clone(),
CliError::Bip39(e) => friendly_bip39(e),
CliError::Codex32(e) => friendly_codex32(e),
CliError::UnexpectedStringLength { got } => {
format!("string length {} not in v0.1 set [50, 56, 62, 69, 75]", got)
}
CliError::PayloadLengthMismatch { got, tag } => format!(
"tag {:?} payload length {} not in expected set [16, 20, 24, 28, 32]",
std::str::from_utf8(tag).unwrap_or("<non-utf8>"),
got
),
CliError::FormatViolation { message, .. } => message.clone(),
CliError::FutureFormat { tag } => format!(
"tag {:?} reserved-not-emitted in v0.1; deferred to v0.2+",
std::str::from_utf8(tag).unwrap_or("<non-utf8>")
),
CliError::VerifyPhraseMismatch => {
"phrase mismatch (decoded does not match --phrase)".to_string()
}
}
}
pub fn details(&self) -> Option<serde_json::Value> {
match self {
CliError::UnexpectedStringLength { got } => Some(json!({
"got": got,
"allowed": [50, 56, 62, 69, 75],
})),
CliError::PayloadLengthMismatch { got, tag } => Some(json!({
"tag": std::str::from_utf8(tag).unwrap_or("<non-utf8>"),
"got": got,
"expected": [16, 20, 24, 28, 32],
})),
CliError::FormatViolation { details, .. } => details.clone(),
CliError::FutureFormat { tag } => Some(json!({
"tag": std::str::from_utf8(tag).unwrap_or("<non-utf8>"),
})),
_ => None,
}
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "error: {}", self.message())
}
}
impl std::error::Error for CliError {}
impl From<bip39::Error> for CliError {
fn from(e: bip39::Error) -> Self {
CliError::Bip39(e)
}
}
impl From<ms_codec::Error> for CliError {
fn from(e: ms_codec::Error) -> Self {
match e {
ms_codec::Error::Codex32(c) => CliError::Codex32(c),
ms_codec::Error::WrongHrp { got } => CliError::FormatViolation {
underlying_kind: "WrongHrp",
message: format!("wrong HRP: got {:?}, expected \"ms\"", got),
details: Some(json!({ "got": got })),
},
ms_codec::Error::ThresholdNotZero { got } => CliError::FormatViolation {
underlying_kind: "ThresholdNotZero",
message: format!(
"threshold not 0 (got '{}'); v0.1 is single-string only",
got as char
),
details: Some(json!({ "got": (got as char).to_string() })),
},
ms_codec::Error::ShareIndexNotSecret { got } => CliError::FormatViolation {
underlying_kind: "ShareIndexNotSecret",
message: format!(
"share-index not 's' (got '{}'); BIP-93 requires 's' for threshold=0",
got
),
details: Some(json!({ "got": got.to_string() })),
},
ms_codec::Error::TagInvalidAlphabet { got } => CliError::FormatViolation {
underlying_kind: "TagInvalidAlphabet",
message: format!("tag bytes not in codex32 alphabet: {:?}", got),
details: Some(json!({ "got_hex": hex::encode(got) })),
},
ms_codec::Error::UnknownTag { got } => CliError::FormatViolation {
underlying_kind: "UnknownTag",
message: format!(
"unknown tag {:?}; not a member of RESERVED_TAG_TABLE",
std::str::from_utf8(&got).unwrap_or("<non-utf8>")
),
details: Some(json!({
"tag": std::str::from_utf8(&got).unwrap_or("<non-utf8>")
})),
},
ms_codec::Error::ReservedTagNotEmittedInV01 { got } => {
CliError::FutureFormat { tag: got }
}
ms_codec::Error::ReservedPrefixViolation { got } => CliError::FormatViolation {
underlying_kind: "ReservedPrefixViolation",
message: format!("reserved-prefix byte was 0x{:02x}, expected 0x00", got),
details: Some(json!({ "got": got })),
},
ms_codec::Error::UnexpectedStringLength { got, allowed: _ } => {
CliError::UnexpectedStringLength { got }
}
ms_codec::Error::PayloadLengthMismatch {
got,
tag,
expected: _,
} => CliError::PayloadLengthMismatch { got, tag },
ms_codec::Error::TooManyErrors { bound } => CliError::FormatViolation {
underlying_kind: "TooManyErrors",
message: format!("more than {} errors; uncorrectable", bound),
details: Some(json!({ "bound": bound })),
},
other => CliError::BadInput(format!("unhandled ms_codec::Error variant: {:?}", other)),
}
}
}
pub type Result<T> = std::result::Result<T, CliError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_code_table_per_variant() {
assert_eq!(CliError::BadInput("x".into()).exit_code(), 1);
assert_eq!(CliError::UnexpectedStringLength { got: 51 }.exit_code(), 1);
assert_eq!(
CliError::PayloadLengthMismatch {
got: 17,
tag: *b"entr"
}
.exit_code(),
1
);
assert_eq!(
CliError::FormatViolation {
underlying_kind: "WrongHrp",
message: "x".into(),
details: None,
}
.exit_code(),
2
);
assert_eq!(CliError::FutureFormat { tag: *b"seed" }.exit_code(), 3);
assert_eq!(CliError::VerifyPhraseMismatch.exit_code(), 4);
}
#[test]
fn from_ms_codec_dispatches_correctly() {
let e: CliError = ms_codec::Error::WrongHrp { got: "mq".into() }.into();
assert_eq!(e.kind(), "WrongHrp");
assert_eq!(e.exit_code(), 2);
let e: CliError = ms_codec::Error::ReservedTagNotEmittedInV01 { got: *b"seed" }.into();
assert_eq!(e.kind(), "FutureFormat");
assert_eq!(e.exit_code(), 3);
let e: CliError = ms_codec::Error::UnexpectedStringLength {
got: 51,
allowed: &[],
}
.into();
assert_eq!(e.kind(), "UnexpectedStringLength");
assert_eq!(e.exit_code(), 1);
}
#[test]
fn details_carries_structure_for_format_violations() {
let e: CliError = ms_codec::Error::ReservedPrefixViolation { got: 0x01 }.into();
let details = e.details().expect("FormatViolation has details");
assert_eq!(details["got"], 1);
}
#[test]
fn too_many_errors_maps_to_format_violation_exit_2() {
let e: CliError = ms_codec::Error::TooManyErrors { bound: 8 }.into();
assert_eq!(e.kind(), "TooManyErrors");
assert_eq!(e.exit_code(), 2);
let details = e.details().expect("FormatViolation has details");
assert_eq!(details["bound"], 8);
}
#[test]
fn kind_for_format_violation_carries_underlying() {
let e: CliError = ms_codec::Error::TagInvalidAlphabet { got: [b'A'; 4] }.into();
assert_eq!(e.kind(), "TagInvalidAlphabet");
}
#[test]
fn display_includes_message() {
let e = CliError::BadInput("test message".into());
assert_eq!(e.to_string(), "error: test message");
}
}