use alloc::string::String;
use alloc::vec::Vec;
use bip39::Language;
use crate::DeriveError;
const MIN_PREFIX_LEN: usize = 4;
pub fn expand(phrase: &str) -> Result<String, DeriveError> {
expand_in(Language::English, phrase)
}
pub fn expand_in(language: Language, phrase: &str) -> Result<String, DeriveError> {
let word_list = language.word_list();
let tokens: Vec<&str> = phrase.split_whitespace().collect();
let mut result = String::new();
for (i, token) in tokens.iter().enumerate() {
let word = resolve_token(word_list, token)?;
if i > 0 {
result.push(' ');
}
result.push_str(word);
}
Ok(result)
}
fn resolve_token<'a>(word_list: &'a [&'a str; 2048], token: &str) -> Result<&'a str, DeriveError> {
if let Ok(idx) = word_list.binary_search(&token) {
return word_list
.get(idx)
.copied()
.ok_or_else(|| DeriveError::Input(alloc::format!("mnemonic: unknown word '{token}'")));
}
if token.len() < MIN_PREFIX_LEN {
return Err(DeriveError::Input(alloc::format!(
"mnemonic: prefix '{token}' is too short (minimum {MIN_PREFIX_LEN} characters)"
)));
}
let start = word_list.partition_point(|w| *w < token);
let matches: Vec<&str> = word_list
.get(start..)
.unwrap_or_default()
.iter()
.take_while(|w| w.starts_with(token))
.copied()
.collect();
match matches.as_slice() {
[] => Err(DeriveError::Input(alloc::format!(
"mnemonic: prefix '{token}' does not match any BIP-39 word"
))),
[only] => Ok(*only),
many => Err(DeriveError::Input(alloc::format!(
"mnemonic: prefix '{token}' is ambiguous, matches: {}",
many.join(", ")
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FULL_12: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn full_words_unchanged() {
let result = expand(FULL_12).unwrap();
assert_eq!(result, FULL_12);
}
#[test]
fn four_letter_prefix_expansion() {
let abbreviated = "aban aban aban aban aban aban aban aban aban aban aban abou";
let result = expand(abbreviated).unwrap();
assert_eq!(result, FULL_12);
}
#[test]
fn mixed_full_and_abbreviated() {
let input =
"abandon aban abandon aban abandon aban abandon aban abandon aban abandon about";
let result = expand(input).unwrap();
assert_eq!(result, FULL_12);
}
#[test]
fn longer_prefix_works() {
let input =
"abando abando abando abando abando abando abando abando abando abando abando about";
let result = expand(input).unwrap();
assert_eq!(result, FULL_12);
}
#[test]
fn prefix_too_short_rejected() {
let result = expand("aba aba aba aba aba aba aba aba aba aba aba aba");
let err = result.unwrap_err();
let DeriveError::Input(msg) = &err else {
unreachable!("expected Input error, got {err:?}");
};
assert!(msg.contains("too short"), "unexpected message: {msg}");
}
#[test]
fn unknown_prefix_rejected() {
let result = expand("aban aban aban aban aban aban aban aban aban aban aban zzzz");
let err = result.unwrap_err();
let DeriveError::Input(msg) = &err else {
unreachable!("expected Input error, got {err:?}");
};
assert!(msg.contains("does not match"), "unexpected message: {msg}");
}
#[test]
fn ambiguous_prefix_rejected() {
let result = expand("aba");
assert!(result.is_err());
}
#[test]
fn preserves_word_count() {
let abbreviated = "aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban aban art";
let result = expand(abbreviated).unwrap();
assert_eq!(result.split_whitespace().count(), 24);
}
#[test]
fn different_words_expand_correctly() {
let input = "abil acti addr admi wall wris";
let result = expand(input).unwrap();
assert_eq!(result, "ability action address admit wall wrist");
}
#[test]
fn exact_short_words_accepted() {
let result = expand("zoo art ice");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "zoo art ice");
}
}