use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PublicId(String);
impl TryFrom<String> for PublicId {
type Error = anyhow::Error;
fn try_from(text: String) -> Result<Self> {
Self::parse(&text)
}
}
impl From<PublicId> for String {
fn from(id: PublicId) -> Self {
id.0
}
}
impl PublicId {
pub fn parse(text: &str) -> Result<Self> {
if text.starts_with("Private/") {
bail!(
"{text} is a private account; fund a public one and move it privately from there"
);
}
let id = text.strip_prefix("Public/").unwrap_or(text);
match bs58::decode(id).into_vec() {
Ok(bytes) if bytes.len() == 32 => Ok(Self(id.to_owned())),
_ => bail!("{text} is not an account id (32 bytes of base58)"),
}
}
pub fn from_hex(hex: &str) -> Result<Self> {
let bytes = (0..hex.len())
.step_by(2)
.map(|i| {
hex.get(i..i + 2)
.and_then(|b| u8::from_str_radix(b, 16).ok())
})
.collect::<Option<Vec<u8>>>();
match bytes {
Some(bytes) if bytes.len() == 32 && hex.len() == 64 => {
Ok(Self(bs58::encode(bytes).into_string()))
}
_ => bail!("{hex} is not an account id (64 hex characters)"),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn wallet_form(&self) -> String {
format!("Public/{}", self.0)
}
pub fn hex(&self) -> String {
let bytes = bs58::decode(&self.0)
.into_vec()
.expect("validated in parse");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
}
pub fn first_public_id(output: &str) -> Option<PublicId> {
output
.split(|c: char| c.is_whitespace())
.filter_map(|word| word.strip_prefix("Public/"))
.find_map(|id| PublicId::parse(id).ok())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_ID: &str = "HpavNGFYxjyhLsy171aXsvv146yUeXmRWiho22uvT43d";
const SAMPLE_HEX: &str = "f9eba477278c918fadafbcb4a6463e947d0fd8de344b5431ee0aa3cd41d7e3a4";
#[test]
fn parses_bare_and_prefixed_ids() {
assert_eq!(PublicId::parse(SAMPLE_ID).unwrap().as_str(), SAMPLE_ID);
let prefixed = PublicId::parse(&format!("Public/{SAMPLE_ID}")).unwrap();
assert_eq!(prefixed.as_str(), SAMPLE_ID);
assert_eq!(prefixed.wallet_form(), format!("Public/{SAMPLE_ID}"));
}
#[test]
fn converts_to_and_from_hex() {
assert_eq!(PublicId::parse(SAMPLE_ID).unwrap().hex(), SAMPLE_HEX);
assert_eq!(PublicId::from_hex(SAMPLE_HEX).unwrap().as_str(), SAMPLE_ID);
for bad in [
"",
"f9",
&SAMPLE_HEX[..63],
&format!("{SAMPLE_HEX}00"),
&"zz".repeat(32),
] {
assert!(PublicId::from_hex(bad).is_err(), "{bad:?}");
}
}
#[test]
fn refuses_private_and_malformed_ids() {
assert!(PublicId::parse(&format!("Private/{SAMPLE_ID}")).is_err());
assert!(PublicId::parse("not-base58-0OIl").is_err());
assert!(PublicId::parse("abc").is_err(), "too short");
}
#[test]
fn finds_the_new_account_in_wallet_output() {
let output = format!(
"Stored persistent accounts at /w/storage.json\n\
Generated new account with account_id Public/{SAMPLE_ID} at path /0\n\
With pk deb1830a"
);
assert_eq!(first_public_id(&output).unwrap().as_str(), SAMPLE_ID);
assert!(first_public_id("no account here").is_none());
}
}