In-browser Hangman game in Rust via WASM
mod utils;

use std::{
    collections::HashMap,
    convert::TryFrom,
    ops::{Deref, DerefMut},
};
use wasm_bindgen::prelude::*;

#[derive(Debug)]
#[wasm_bindgen]
pub enum HangmanError {
    InvalidLetter,
    AlreadyGuessed,
    GameOver,
}

impl From<HangmanError> for JsValue {
    fn from(he: HangmanError) -> Self {
        JsValue::from(match he {
            HangmanError::InvalidLetter => "Invalid Letter!",
            HangmanError::AlreadyGuessed => "Already Guessed!",
            HangmanError::GameOver => "Game is over!",
        })
    }
}

#[wasm_bindgen]
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
pub struct InternalLetter(u8);

impl AsRef<u8> for InternalLetter {
    fn as_ref(&self) -> &u8 {
        &self.0
    }
}

impl From<InternalLetter> for u8 {
    fn from(l: InternalLetter) -> Self {
        l.0
    }
}

impl From<InternalLetter> for String {
    fn from(l: InternalLetter) -> Self {
        let u: u8 = l.into();
        let s: String = std::str::from_utf8(&[u])
            .expect("InternalLetters are always valid utf-8")
            .to_string();
        s
    }
}

impl TryFrom<char> for InternalLetter {
    type Error = HangmanError;

    fn try_from(value: char) -> Result<Self, Self::Error> {
        let mut buf: [u8; 4] = [0; 4];
        if value.is_ascii_alphabetic() || value == '_' {
            let value = value.to_ascii_uppercase();
            value.encode_utf8(&mut buf);
            let byte: u8 = buf[0];
            assert_eq!(value, byte as char);
            Ok(InternalLetter(byte))
        } else {
            Err(HangmanError::InvalidLetter)
        }
    }
}

impl From<InternalLetter> for char {
    fn from(l: InternalLetter) -> Self {
        l.0 as char
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
#[wasm_bindgen]
pub enum GameState {
    Won,
    Lost,
    InProgress,
}

#[wasm_bindgen(inspectable, readonly)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum LetterGuessedState {
    CorrectGuess,
    Unguessed,
}

#[wasm_bindgen(readonly, inspectable)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum GuessResult {
    Correct,
    Incorrect,
}

fn string_repr<T>(source: &Vec<T>, char_mapper: impl FnMut(&T) -> u8) -> String {
    let entries: Vec<u8> = source.iter().map(char_mapper).collect();
    let entries = entries.as_slice();
    std::str::from_utf8(entries)
        .expect("All chars are valid UTF-8")
        .to_string()
}

#[derive(PartialEq, Eq)]
#[wasm_bindgen(inspectable)]
pub struct WordLetter {
    #[wasm_bindgen(readonly)]
    pub letter: InternalLetter,
    #[wasm_bindgen(readonly)]
    pub state: LetterGuessedState,
}

impl WordLetter {
    pub fn guessed(&self) -> bool {
        self.state == LetterGuessedState::CorrectGuess
    }
}

#[wasm_bindgen(inspectable)]
pub struct GuessLetter {
    #[wasm_bindgen(readonly)]
    pub letter: InternalLetter,
    #[wasm_bindgen(readonly)]
    pub state: GuessResult,
}

struct WordList {}

impl WordList {
    pub fn select_random_word() -> Word {
        Word(
            "EGGPLANT"
                .chars()
                .map(|c| {
                    let letter =
                        InternalLetter::try_from(c).expect("WordList always uses valid chars");
                    WordLetter {
                        letter,
                        state: LetterGuessedState::Unguessed,
                    }
                })
                .collect(),
        )
    }
}

#[wasm_bindgen]
pub struct Word(Vec<WordLetter>);

impl Deref for Word {
    type Target = Vec<WordLetter>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Word {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl Word {
    pub fn guess(&mut self, guess: &InternalLetter) -> GuessResult {
        let mut guess_result: GuessResult = GuessResult::Incorrect;
        self.iter_mut().for_each(|f| {
            if f.letter == *guess {
                f.state = LetterGuessedState::CorrectGuess;
                guess_result = GuessResult::Correct;
            }
        });

        guess_result
    }

    pub fn completely_guessed(&self) -> bool {
        self.0.iter().all(WordLetter::guessed)
    }
}

#[wasm_bindgen(inspectable)]
pub struct Game {
    #[wasm_bindgen(skip)]
    pub guesses_left: u8,
    current_word: Word,
    guesses: HashMap<InternalLetter, GuessResult>,
}

#[wasm_bindgen]
impl Game {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        Self {
            guesses_left: 6,
            guesses: HashMap::with_capacity(15),
            current_word: WordList::select_random_word(),
        }
    }

    #[wasm_bindgen(getter)]
    pub fn guesses_left(&self) -> u8 {
        self.guesses_left
    }

    #[wasm_bindgen(getter)]
    #[inline]
    pub fn state(&self) -> GameState {
        if self.guesses_left == 0 {
            GameState::Lost
        } else if self.current_word.completely_guessed() {
            GameState::Won
        } else {
            GameState::InProgress
        }
    }

    #[wasm_bindgen(getter)]
    pub fn current_word(&self) -> String {
        match self.state() {
            GameState::InProgress => Self::to_in_progress(&self.current_word),
            _ => Self::to_game_end(&self.current_word),
        }
    }

    #[wasm_bindgen]
    pub fn guess_letter(&mut self, letter: char) -> Result<GameState, HangmanError> {
        let guess = InternalLetter::try_from(letter).or(Err(HangmanError::InvalidLetter))?;
        if self.guesses.get(&guess).is_some() {
            return Err(HangmanError::AlreadyGuessed);
        } else if self.state() != GameState::InProgress {
            return Err(HangmanError::GameOver);
        };

        let result = self.current_word.guess(&guess);

        match result {
            GuessResult::Incorrect => {
                self.guesses_left -= 1;
            }
            GuessResult::Correct => {}
        }

        Ok(self.state())
    }

    fn to_in_progress(letters: &Vec<WordLetter>) -> String {
        let mapper = |l: &WordLetter| match l {
            WordLetter {
                letter,
                state: LetterGuessedState::CorrectGuess,
            } => (u8::from(*letter)),
            WordLetter {
                state: LetterGuessedState::Unguessed,
                ..
            } => b"_"[0],
        };
        string_repr(letters, mapper)
    }

    fn to_game_end(letters: &Vec<WordLetter>) -> String {
        let mapper = |l: &WordLetter| l.letter.into();
        string_repr(letters, mapper)
    }

    #[wasm_bindgen(getter)]
    pub fn correct_guesses(&self) -> Box<[JsValue]> {
        let correct: Vec<JsValue> = self
            .guesses
            .iter()
            .filter_map(|(letter, guess)| {
                if let GuessResult::Correct = guess {
                    let s: String = (*letter).into();
                    let j: JsValue = (*s).into();
                    Some(j)
                } else {
                    None
                }
            })
            .collect();
        correct.into_boxed_slice()
    }

    #[wasm_bindgen(getter, skip_typescript)]
    pub fn incorrect_guesses(&self) -> Box<[JsValue]> {
        let incorrect: Vec<JsValue> = self
            .guesses
            .iter()
            .filter_map(|(letter, guess)| {
                if let GuessResult::Incorrect = guess {
                    let s: String = (*letter).into();
                    let j: JsValue = (*s).into();
                    Some(j)
                } else {
                    None
                }
            })
            .collect();
        incorrect.into_boxed_slice()
    }
}

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, hangman!");
}

#[cfg(test)]
mod tests {
    use std::convert::TryInto;

    use super::*;

    #[test]
    fn internal_letter_works_for_letters_and_underscore() -> Result<(), HangmanError> {
        let lower: char = 'a';
        let upper: char = 'Z';
        let under: char = '_';

        let lower_r: InternalLetter = lower.try_into()?;
        assert_eq!("A".to_string(), String::from(lower_r));
        let upper_r: InternalLetter = upper.try_into()?;
        assert_eq!("Z".to_string(), String::from(upper_r));
        let under_r: InternalLetter = under.try_into()?;
        assert_eq!("_".to_string(), String::from(under_r));
        Ok(())
    }

    #[test]
    fn internal_letter_does_not_work_for_digits() {
        let digit = '9';

        assert!(InternalLetter::try_from(digit).is_err());
    }
}