6
\$\begingroup\$

I wrote the following CLI for playing the popular word game - Wordle. Wordle is a word game in which players are trying to guess a 5 letter word. Each turn they guess one 5 letter word, and for each letter are told that it either isn't in the word, is in the word, but not here, or is correctly placed.

This CLI attempts to play the game by taking a wordlist, guessing words that cut down the remaining words (e.g. by maximising the number of new characters in it) until there are only two possible words left.

Users can either provide the answer up front using the --answer flag, or respond interactively to each guess, specifying whether it is present, absent or correct.

I'm primarily looking for feedback on how "rusty" the code is and how it can be made more idiomatic. In particular:

  • is the borrowing right? I felt like a lot of the time I was just making changes to make the compiler happy!
  • are the tests structured in the normal way?
  • is the module breakdown suitable?
  • are there well known libraries I should be using?

I am experienced with other languages (C++/C#) but new to Rust.

main.rs

mod game;
mod cli;
mod interactive_solver;
mod non_interactive_solver;
mod player;

fn main() {
    cli::run_cli();
}

game.rs

pub const GAME_WORD_LENGTH: usize = 5;

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug)]
pub enum LetterResponse {
    Correct,
    InWord,
    NotInWord,
}

pub struct GuessResponse {
    pub letter_responses: Vec<LetterResponse>,
}

pub fn is_guess_correct(response: &GuessResponse) -> bool {
    response
        .letter_responses
        .iter()
        .all(|&l| l == LetterResponse::Correct)
}

#[cfg(test)]
mod is_guess_correct_tests {
    use super::*;

    #[test]
    fn all_correct_is_correct() {
        let response = GuessResponse {
            letter_responses: [LetterResponse::Correct; 5].to_vec(),
        };
        assert!(is_guess_correct(&response));
    }

    #[test]
    fn one_in_correct_is_not_correct() {
        let response = GuessResponse {
            letter_responses: [
                LetterResponse::Correct,
                LetterResponse::Correct,
                LetterResponse::Correct,
                LetterResponse::InWord,
                LetterResponse::Correct,
            ]
            .to_vec(),
        };
        assert!(!is_guess_correct(&response));
    }
}

cli.rs

use crate::game;
use crate::game::GuessResponse;
use crate::interactive_solver;
use crate::non_interactive_solver;
use crate::player;
use clap::Parser;
use std::fs;

#[derive(Parser, Debug)]
#[clap(about, version, author)]
struct Args {
    #[clap(long, default_value = "words_alpha.txt")]
    word_list_path: String,

    #[clap(long)]
    answer: Option<String>,
}

pub fn run_cli() {
    let args = Args::parse();

    let word_list = read_word_list(args.word_list);
    let verifier: Box<dyn Fn(&str) -> GuessResponse> = match args.answer {
        Some(word) => Box::new(move |guess: &str| {
            let answer_word = word.clone();
            non_interactive_solver::non_interactive_solver(guess, answer_word)
        }),
        None => Box::new(move |guess: &str| interactive_solver::interactive_solver(&guess)),
    };

    let sln = player::solve(&word_list, verifier);
    println!("Solution: {:?}", sln.guess_sequence);
}

fn read_word_list(word_list_file_name: String) -> Vec<String> {
    fs::read_to_string(word_list_file_name)
        .expect("Error reading file")
        .split_ascii_whitespace()
        .filter(|s| s.len() == game::GAME_WORD_LENGTH)
        .filter(|s| s.chars().all(|c| c.is_alphabetic()))
        .map(str::to_string)
        .collect()
}

interactive_solver.rs

use crate::game;
use crate::game::GuessResponse;
use crate::game::LetterResponse;
use std::io;

pub fn interactive_solver(guess: &str) -> GuessResponse {
    println!("Guess: {}", guess);
    let mut response = String::new();
    println!("Type a {} letter response - y: correct, . - in word, x - not involved", game::GAME_WORD_LENGTH);

    loop {
        io::stdin()
            .read_line(&mut response)
            .expect("Failed to read line");
        response.pop();
        match response.len() {
            game::GAME_WORD_LENGTH => {
                let parsed_response = parse_user_response(&response);
                match parsed_response {
                    Ok(response) => return response,
                    Err(e) => println!("Invalid string: {}", e),
                }
            }

            _ => {
                println!("Enter exactly {} characters, {}", game::GAME_WORD_LENGTH, response.len())
            }
        }
    }
}

fn parse_user_response(response_str: &String) -> Result<GuessResponse, String> {
    if response_str.len() != game::GAME_WORD_LENGTH {
        panic!(format!("Must call parse_user_response with exactly {} characters", game::GAME_WORD_LENGTH));
    }
    let mapped_response: Result<Vec<_>, _> = response_str
        .chars()
        .map(|char| match char {
            'y' => Ok(LetterResponse::Correct),
            '.' => Ok(LetterResponse::InWord),
            'x' => Ok(LetterResponse::NotInWord),
            x => Err(format!("Invalid character: {}", x)),
        })
        .collect();

    return match mapped_response {
        Ok(x) => Ok(GuessResponse {
            letter_responses: x.to_vec(),
        }),
        Err(e) => Err(format!("Invalid response: {}", e)),
    };
}

#[cfg(test)]
mod parse_user_response_tests {
    use super::*;

    #[test]
    fn valid_string_parsed_correctly() {
        // If I inline this, it is apparently still needed for checking response.is_ok (lazy eval of parse_user_response?)
        let user_input = String::from("y.x.x");
        let response = parse_user_response(&user_input);
        assert!(response.is_ok());
        assert_eq!(
            response.unwrap().letter_responses,
            vec![
                LetterResponse::Correct,
                LetterResponse::InWord,
                LetterResponse::NotInWord,
                LetterResponse::InWord,
                LetterResponse::NotInWord
            ]
        );
    }

    #[test]
    fn one_invalid_char_invalid_response() {
        let user_input = String::from("y.xax");
        let response = parse_user_response(&user_input);
        assert!(response.is_err());
    }
}

non_interactive_solver.rs

use crate::game::GuessResponse;
use crate::game::LetterResponse;

pub fn non_interactive_solver(guess: &str, answer: String) -> GuessResponse {
    GuessResponse {
        letter_responses: guess
            .chars()
            .enumerate()
            .map(|(index, char)| {
                if answer
                    .chars()
                    .nth(index)
                    .expect("Word length different from guess length")
                    == char
                {
                    return LetterResponse::Correct;
                } else if answer.contains(char) {
                    return LetterResponse::InWord;
                } else {
                    return LetterResponse::NotInWord;
                }
            })
            .collect(),
    }
}

#[cfg(test)]
mod non_interactive_solver_tests {
    use super::*;
    #[test]
    fn words_same_all_correct() {
        let guess = "hello";
        let answer = "hello";
        let result = non_interactive_solver(&guess, String::from(answer));
        assert!(result
            .letter_responses
            .iter()
            .all(|&l| l == LetterResponse::Correct))
    }

    #[test]
    fn letter_in_not_right_place() {
        let guess = "abc";
        let answer = "dea";
        let result = non_interactive_solver(&guess, String::from(answer));
        assert!(result.letter_responses[0] == LetterResponse::InWord);
        assert!(result.letter_responses[1] == LetterResponse::NotInWord);
        assert!(result.letter_responses[2] == LetterResponse::NotInWord);
    }
}

player.rs

use crate::game;
use crate::game::GuessResponse;
use crate::game::LetterResponse;
use itertools::Itertools;
use std::collections::HashMap;

pub struct Solution {
    pub guess_sequence: Vec<String>,
}

struct Knowledge {
    guessed_words: Vec<String>,
    correct_letters: Vec<(char, usize)>,
    contained_letters: HashMap<char, Vec<usize>>,
}

fn build_empty_knowledge() -> Knowledge {
    Knowledge {
        guessed_words: vec![],
        correct_letters: vec![],
        contained_letters: HashMap::new(),
    }
}

pub fn solve<VFn>(possbile_words: &Vec<String>, verifier: VFn) -> Solution
where
    VFn: Fn(&str) -> GuessResponse,
{
    solve_rec(possbile_words, verifier, &build_empty_knowledge())
}

fn solve_rec<VFn>(
    possbile_words: &Vec<String>,
    verifier: VFn,
    starting_knowledge: &Knowledge,
) -> Solution
where
    VFn: Fn(&str) -> GuessResponse,
{
    let guess = make_guess(possbile_words, starting_knowledge);
    let response = verifier(&guess);
    let new_knowledge = apply_learning(starting_knowledge, &guess, &response);

    match game::is_guess_correct(&response) {
        true => Solution {
            guess_sequence: new_knowledge.guessed_words,
        },
        false => solve_rec(possbile_words, verifier, &new_knowledge),
    }
}

fn apply_learning(knowledge: &Knowledge, guess: &String, response: &GuessResponse) -> Knowledge {
    let mut new_words = knowledge.guessed_words.clone();
    new_words.push(guess.to_string());
    Knowledge {
        guessed_words: new_words,
        correct_letters: knowledge
            .correct_letters
            .iter()
            .map(|t| t.clone())
            .chain(
                response
                    .letter_responses
                    .iter()
                    .enumerate()
                    .filter(|(_, &char)| char == LetterResponse::Correct)
                    .map(|(index, &_)| (guess.chars().nth(index).unwrap(), index))
                    .collect::<Vec<(char, usize)>>(),
            )
            .collect::<Vec<(char, usize)>>(),
        contained_letters: merge_contained_letters_map(
            &knowledge.contained_letters,
            response
                .letter_responses
                .iter()
                .enumerate()
                .filter(|(_, &char)| char == LetterResponse::InWord)
                .map(|(index, &_)| (guess.chars().nth(index).unwrap(), index)),
        ),
    }
}

fn merge_contained_letters_map<I>(
    original: &HashMap<char, Vec<usize>>,
    newly_tried_letters: I,
) -> HashMap<char, Vec<usize>>
where
    I: Iterator<Item = (char, usize)>,
{
    let mut new_map = original.clone();
    for (char, pos_tried) in newly_tried_letters {
        if new_map.contains_key(&char) {
            let mut current_vec = new_map[&char].clone();
            current_vec.push(pos_tried);
            new_map.insert(char, current_vec);
        } else {
            new_map.insert(char, vec![pos_tried]);
        }
    }
    new_map
}

#[cfg(test)]
mod apply_learning_tests {
    use super::*;

    #[test]
    fn empty_knowledge_correct_letter_added_to_list_of_correct_letters() {
        let knowledge = build_empty_knowledge();
        let response = GuessResponse {
            letter_responses: [
                LetterResponse::Correct,
                LetterResponse::NotInWord,
                LetterResponse::NotInWord,
            ]
            .to_vec(),
        };
        let new_knowledge = apply_learning(&knowledge, &String::from("abc"), &response);
        assert!(new_knowledge.correct_letters.len() == 1);
        assert!(new_knowledge.correct_letters[0] == ('a', 0));
    }

    #[test]
    fn empty_knowledge_contained_letters_added_to_list_of_contained_letters() {
        let knowledge = build_empty_knowledge();
        let response = GuessResponse {
            letter_responses: [
                LetterResponse::InWord,
                LetterResponse::NotInWord,
                LetterResponse::NotInWord,
            ]
            .to_vec(),
        };
        let new_knowledge = apply_learning(&knowledge, &String::from("abc"), &response);
        assert!(new_knowledge.correct_letters.len() == 0);
        assert!(new_knowledge.contained_letters.len() == 1);
        assert!(new_knowledge.contained_letters.contains_key(&'a'));
        assert_eq!(new_knowledge.contained_letters[&'a'], vec![0]);
    }

    #[test]
    fn knowledge_about_letter_in_word_extended() {
        let knowledge = Knowledge {
            contained_letters: HashMap::from([('a', vec![0])]),
            ..build_empty_knowledge()
        };
        let response = GuessResponse {
            letter_responses: [
                LetterResponse::NotInWord,
                LetterResponse::InWord,
                LetterResponse::NotInWord,
            ]
            .to_vec(),
        };
        let new_knowledge = apply_learning(&knowledge, &String::from("bac"), &response);
        assert!(new_knowledge.correct_letters.len() == 0);
        assert!(new_knowledge.contained_letters.len() == 1);
        assert!(new_knowledge.contained_letters.contains_key(&'a'));
        assert_eq!(new_knowledge.contained_letters[&'a'], vec![0, 1]);
    }
}

fn make_guess(possbile_words: &Vec<String>, knowledge: &Knowledge) -> String {
    let valid_words = possbile_words
        .iter()
        .filter(|w| {
            knowledge
                .correct_letters
                .iter()
                .all(|(char, index)| w.chars().nth(*index).unwrap() == *char)
        })
        .filter(|w| {
            knowledge
                .contained_letters
                .iter()
                .all(|char| w.chars().contains(char.0))
        })
        .filter(|w| {
            knowledge
                .contained_letters
                .iter()
                .all(|(char, tried_indexes)| {
                    tried_indexes
                        .iter()
                        .all(|tried_index| w.chars().nth(*tried_index).unwrap() != *char)
                })
        })
        .filter(|w| !knowledge.guessed_words.contains(w))
        .cloned()
        .collect::<Vec<String>>();
    if valid_words.len() > 2 {
        let guess = revealing_word(&valid_words, &knowledge);
        println!("{} possibilities, trying {}", valid_words.len(), guess);
        guess
    } else {
        valid_words.first().unwrap().to_string()
    }
}

fn revealing_word(possbile_words: &Vec<String>, knowledge: &Knowledge) -> String {
    let letter_frequency = "abcdefghijklmnopqrstuvwxyz"
        .chars()
        .map(|c| (c, possbile_words.iter().map(|w| w.matches(c).count()).sum()))
        .collect::<HashMap<char, usize>>();

    possbile_words
        .iter()
        .max_by(|w1, w2| {
            let w1_score = word_score(&w1, &letter_frequency, &knowledge);
            let w2_score = word_score(&w2, &letter_frequency, &knowledge);
            w1_score.cmp(&w2_score)
        })
        .unwrap()
        .clone()
}

fn word_score(
    word: &String,
    char_frequence: &HashMap<char, usize>,
    knowledge: &Knowledge,
) -> usize {
    word.chars()
        .unique() // each letter only gets scored once
        .map(|c_in_word| {
            if knowledge.contained_letters.contains_key(&c_in_word) {
                return char_frequence[&c_in_word];
            } else if knowledge
                .correct_letters
                .iter()
                .any(|(letter, _)| *letter == c_in_word)
            {
                return char_frequence[&c_in_word];
            } else if knowledge
                .guessed_words
                .iter()
                .any(|w| w.contains(c_in_word))
            {
                return 0;
            }
            return char_frequence[&c_in_word];
        })
        .sum()
}

#[cfg(test)]
mod make_guess_tests {
    use super::*;

    #[test]
    fn guessed_one_word_dont_guess_again() {
        let words = [String::from("foo"), String::from("bar")].to_vec();
        let knowledge = Knowledge {
            guessed_words: [String::from("foo")].to_vec(),
            ..build_empty_knowledge()
        };
        let guess = make_guess(&words, &knowledge);
        assert!(guess == "bar");
    }

    #[test]
    fn guessed_one_correct_letter_guess_next_valid_word() {
        let words = [
            String::from("abc"),
            String::from("bcd"),
            String::from("abd"),
        ]
        .to_vec();
        let knowledge = Knowledge {
            guessed_words: vec![String::from("abc")],
            correct_letters: vec![('a', 0)],
            ..build_empty_knowledge()
        };
        let guess = make_guess(&words, &knowledge);
        assert!(guess == "abd");
    }

    #[test]
    fn guessed_one_contained_letter_guess_next_word_that_contains_in_different_position() {
        let words = vec![
            String::from("abc"),
            String::from("bcd"),
            String::from("acd"),
            String::from("bac"),
        ];
        let knowledge = Knowledge {
            guessed_words: vec![String::from("abc")],
            contained_letters: HashMap::from([('a', vec![0])]),
            ..build_empty_knowledge()
        };
        let guess = make_guess(&words, &knowledge);
        assert!(guess == "bac");
    }
}
```
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

I took a quick look at your code and would love to share a few ideas.

Creating Structs

By convention, when creating a struct you'll usually see this done in a fn new() -> Self function that resides in the structs impl block. So instead of build_empty_knowledge() -> Knowledge you might have something like the below:

impl Knowledge {
    fn new() -> Self {
        Knowledge {
            guessed_words: vec![],
            correct_letters: vec![],
            contained_letters: HashMap::new(),
        }
    }
}

Now anytime you'd like to create new knowledge.

let knowledge = Knowledge::new();

Expressive Structs

There are a few instances where references to structs are manually being passed to a function. This is fine, but in an attempt to make the code a bit more expressive, we can move some of these functions into our struct impl block.

Consider the below:

pub fn is_guess_correct(response: &GuessResponse) -> bool {
    response
        .letter_responses
        .iter()
        .all(|&l| l == LetterResponse::Correct)
}
impl Knowledge {
    pub fn is_correct(&self) -> bool {
        response.letter_responses
            .iter()
            .all(|&l| l == LetterResponse::Correct)
    }
}

Now when determining if a response is correct we can just call response.is_correct(), which is just a little nicer to look at I think. I come from a heavy OOP background though so take this with a grain of salt. But I would imagine Rust would also appreciate decisions like this.

General Readability

There is a ton of inlining statements and closures throughout the code, and it really affects the readability.

Take this snippet for example:

GuessResponse {
        letter_responses: guess
            .chars()
            .enumerate()
            .map(|(index, char)| {
                if answer
                    .chars()
                    .nth(index)
                    .expect("Word length different from guess length")
                    == char
                {
                    return LetterResponse::Correct;
                } else if answer.contains(char) {
                    return LetterResponse::InWord;
                } else {
                    return LetterResponse::NotInWord;
                }
            })
            .collect(),
    }

We could make this a little easier on the eyes and for other readers by naming the inlined boolean expression and by pulling the somewhat involved closure out into a named function.

pub fn non_interactive_solver(guess: &str, answer: &str) -> GuessResponse {
    GuessResponse {
        letter_responses: guess
            .chars()
            .enumerate()
            .map(|(index, char)| to_letter_response(answer, char, index))
            .collect(),
    }
}

fn to_letter_response(answer: &str, guess_char: char, guess_index: usize) -> LetterResponse {
    let answer_char = answer
        .chars()
        .nth(guess_index)
        .expect("Word length different from guess length");

    return if answer_char == guess_char {
        LetterResponse::Correct
    } else if answer.contains(guess_char) {
        LetterResponse::InWord
    } else {
        LetterResponse::NotInWord
    };
}

By refactoring to the above, as readers we have a bit more context to what these constructs are doing/referencing. There are a ton of examples similar to this scattered throughout the code base.

assert! vs assert_eq!

Should probably use the assert_eq! macro over assert! when comparing equality. assert_eq! will give better message regarding the equality.

assert!(new_knowledge.correct_letters.len() == 1);
assert_eq!(new_knowledge.correct_letters.len(), 1);

Redundant

When parsing user commands, there is no need to use the format! macro. panic! supports the same string interpolation.

panic!("Must call parse_user_response with exactly {} characters", game::GAME_WORD_LENGTH);

The possible_words parameter in this function is not being used.

fn word_score(
    word: &String,
    possible_words: &HashMap<char, usize>,
    knowledge: &Knowledge,
) -> usize { 
    ...
}

Typos

possbile_words -> possible_words
char_frequence -> char_frequency or char_frequencies

\$\endgroup\$
1
  • \$\begingroup\$ Thank you for taking the time to provide this feedback - it looks very useful. I have a couple of other things I've spotted which I'll add as an extra answer to compliment this. \$\endgroup\$
    – T. Kiley
    Feb 3, 2022 at 22:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.