Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Learning Rust #10: Added new feature with a HashMap

In December 2020 I finally started learning Rust and built and published my first app with Rust: 235. Learning Rust is a blog series that is definitely not a tutorial but rather a place for me to keep track of my learning and write about things I've learned along the way.

Learning Rust series

For quite a bit, I’ve been thinking about wanting to show the assists of specific players in the output. Yle’s teletext page does this for Finnish players by adding them in parenthesis below the line of a goal scored where those players gained assists.

That was my starting point and in the GitHub linked issue and in my early notes, that’s what I was planning to do. The way the print logic is built right now doesn’t make it super easy though since it operates on a line-by-goals basis.

This fall, I started pondering this again and I figured it’s not as important to know which goals they assisted but rather, how many goals and assists they gained in total.

Since version 1.2.0, users of 235 have been able to write a list of player names they want to follow. This can be achieved by creating a list of last names in $HOME/.235.config file and used with --highlight flag. I decided to use this same list for which players to show their full stats.

In the spirit of Learning Rust series, I’ll take you to a journey of how I crafted this feature and how I went through multiple rounds of refactoring to clean up the code.

Refactoring to Options struct

I have used boolean variables previously to keep track of command line options, passing them through in various functions. With the amount of this value growing to 3 now, I figured it’s time to refactor them to use a single options variable to keep hold of them.

I started by defining a struct for these three options:

struct Options {
    use_colors: bool,
    show_highlights: bool,
    show_stats: bool,
}

and then creating it in the main function:

let options: Options = Options {
    use_colors: !args.nocolors,
    show_stats: args.stats,
    show_highlights: args.highlight,
};

This cleaned up the code down the line significantly.

Crafting the stats message with a HashMap

When it comes time to (potentially) print the stats message, I use a `std::collections::HashMap` to collect points for the desired players.

Coming back to Rust after a full year, I struggled quite a bit in the beginning and my first version was very verbose and overly step-by-step:

fn print_stats(home_scores: &Vec<&Goal>, away_scores: &Vec<&Goal>, highlights: &[String]) {
    let mut stats: HashMap<String, Stat> = HashMap::new();
    home_scores.iter().for_each(|&goal| {
        if highlights.contains(&goal.scorer) {
            let current_score = match stats.get(&goal.scorer) {
                Some(stat) => stat.goals,
                None => 0,
            };

            let current_assists = match stats.get(&goal.scorer) {
                Some(stat) => stat.assists,
                None => 0,
            };

            let new_stat: Stat = Stat {
                goals: current_score + 1,
                assists: current_assists,
            };

            stats.insert(String::from(&goal.scorer), new_stat);
        }

        goal.assists.iter().for_each(|assist| {
            if highlights.contains(&assist) {
                let current_goals = match stats.get(assist) {
                    Some(stat) => stat.goals,
                    None => 0,
                };

                let current_assists = match stats.get(assist) {
                    Some(stat) => stat.assists,
                    None => 0,
                };

                let new_stat: Stat = Stat {
                    goals: current_goals,
                    assists: current_assists + 1,
                };

                stats.insert(String::from(assist), new_stat);
            }
        })
    });

    away_scores.iter().for_each(|&goal| {
        if highlights.contains(&goal.scorer) {
            let current_goals = match stats.get(&goal.scorer) {
                Some(stat) => stat.goals,
                None => 0,
            };

            let current_assists = match stats.get(&goal.scorer) {
                Some(stat) => stat.assists,
                None => 0,
            };

            let new_stat: Stat = Stat {
                goals: current_goals + 1,
                assists: current_assists,
            };

            stats.insert(String::from(&goal.scorer), new_stat);
        }

        goal.assists.iter().for_each(|assist| {
            if highlights.contains(&assist) {
                let current_goals = match stats.get(assist) {
                    Some(stat) => stat.goals,
                    None => 0,
                };

                let current_assists = match stats.get(assist) {
                    Some(stat) => stat.assists,
                    None => 0,
                };

                let new_stat: Stat = Stat {
                    goals: current_goals,
                    assists: current_assists + 1,
                };

                stats.insert(String::from(assist), new_stat);
            }
        })
    });

    if stats.is_empty() {
        return;
    }

    let mut message = String::from("(");
    for (name, stats) in stats.iter() {
        message.push_str(name);
        message.push_str(" ");
        message.push_str(&stats.goals.to_string());
        message.push_str("+");
        message.push_str(&stats.assists.to_string());
        message.push_str(", ");
    }
    let len = message.len();
    message = String::from(&message[..len - 2]);
    message.push_str(")");

    yellow_ln!("{}", message);
    println!();
}

There’s a lot of repetition and a lot of extra work done but it helped me find a way to make something work.

For each goal in home and away scores, I extracted the current goal and assist counts for a player, added one to the correct one and inserted it back to the HashMap.

Let’s start refactoring

First thing I did to start refactoring, was to extract the counting of stats into its own function and combine the goals and assists accessing into one get call and pattern match.

fn count_stats(goal: &Goal, stats: &mut HashMap<String, Stat>, highlights: &[String]) {
    if highlights.contains(&goal.scorer) {
        let new_stat = match stats.get(&goal.scorer) {
            Some(stat) => Stat {
                goals: stat.goals + 1,
                assists: stat.assists,
            },
            None => Stat {
                goals: 1,
                assists: 0,
            },
        };

        stats.insert(String::from(&goal.scorer), new_stat);
    }

    goal.assists.iter().for_each(|assist| {
        if highlights.contains(&assist) {
            let new_stat = match stats.get(&goal.scorer) {
                Some(stat) => Stat {
                    goals: stat.goals,
                    assists: stat.assists + 1,
                },
                None => Stat {
                    goals: 0,
                    assists: 1,
                },
            };

            stats.insert(String::from(assist), new_stat);
        }
    })
}

fn print_stats(
    home_scores: &Vec<&Goal>,
    away_scores: &Vec<&Goal>,
    highlights: &[String],
    show_highlights: bool,
) {
    let mut stats: HashMap<String, Stat> = HashMap::new();

		home_scores.iter().for_each(|&goal| {
        count_stats(&goal, &mut stats, &highlights);
    });

		away_scores.iter().for_each(|&goal| {
        count_stats(&goal, &mut stats, &highlights);
    });

	  // Rest same as above
}

It still felt a bit too manual and I figured there must be a better way. Luckily, I’m part of a great community of Rust developers in Finland through Koodiklinikka community and asked for help in our Slack.

I was pointed out to the trio of methods .entry(), .and_modify() and .or_insert():

fn count_stats(goal: &Goal, stats: &mut HashMap<String, Stat>, highlights: &[String]) {
    if highlights.contains(&goal.scorer) {
        stats
            .entry(String::from(&goal.scorer))
            .and_modify(|stat| stat.goals += 1)
            .or_insert(Stat {
                goals: 1,
                assists: 0,
            });
    }

    goal.assists.iter().for_each(|assist| {
        if highlights.contains(&assist) {
            stats
                .entry(String::from(assist))
                .and_modify(|stat| stat.assists += 1)
                .or_insert(Stat {
                    goals: 0,
                    assists: 1,
                });
        }
    })
}

These allow modification of individual fields within the value of a HashMap with a default insert if the key isn’t found. I love the interface: it’s easy to read and very compact.

After a good night sleep, I also realized it’s bit silly to pass both home and away goals as separate vectors to this function when I already had a list of all goals so I refactored the print function to accept one list of goals and iterate over that:

fn print_stats(goals: &Vec<Goal>, highlights: &[String], options: &Options) {
    let mut stats: HashMap<String, Stat> = HashMap::new();

    goals.iter().for_each(|goal| {
        count_stats(&goal, &mut stats, &highlights);
    });

    if stats.is_empty() {
        return;
    }

    let mut stats_messages: Vec<String> = Vec::new();
    for (name, stats) in stats.iter() {
        let sub_message = format!(
            "{} {}+{}",
            name,
            &stats.goals.to_string(),
            &stats.assists.to_string()
        );
        stats_messages.push(sub_message);
    }
    let message: String = format!("({})", stats_messages.join(", "));

    if options.show_highlights {
        yellow_ln!("{}", message);
    } else if options.use_colors {
        white_ln!("{}", message);
    } else {
        println!("{}", message);
    }
    println!();
}

I also replaced my manual message creation with two changes: I use format! to craft the individual parts for each player inside the for loop and then use join() to combine all of them. And since I’m passing the options struct here, I have a cleaner way to manage the right colors with printing.

With there now only being one goals vector to play with, I can move the HashMap creation into count_stats function and return it from there, instead of creating it one level higher and modifying it inside another function.

fn count_stats(goals: &Vec<Goal>, highlights: &[String]) -> HashMap<String, Stat> {
    let mut stats: HashMap<String, Stat> = HashMap::new();

    goals.iter().for_each(|goal| {
        if highlights.contains(&goal.scorer) {
            stats
                .entry(String::from(&goal.scorer))
                .and_modify(|stat| stat.goals += 1)
                .or_insert(Stat {
                    goals: 1,
                    assists: 0,
                });
        }
        goal.assists.iter().for_each(|assist| {
            if highlights.contains(&assist) {
                stats
                    .entry(String::from(assist))
                    .and_modify(|stat| stat.assists += 1)
                    .or_insert(Stat {
                        goals: 0,
                        assists: 1,
                    });
            }
        })
    });

    return stats;
}

// In print_stats:
let stats: HashMap<String, Stat> = count_stats(&goals, &highlights);

Finally, I extracted the crafting of the stats message to a separate function from the print stats function so it can be tested.

fn craft_stats_message(goals: &Vec<Goal>, highlights: &[String]) -> Option<String> {
    let stats: HashMap<String, Stat> = count_stats(&goals, &highlights);

    if stats.is_empty() {
        return None;
    }

    let mut stats_messages: Vec<String> = Vec::new();
    for (name, stats) in stats.iter() {
        let sub_message = format!(
            "{} {}+{}",
            name,
            &stats.goals.to_string(),
            &stats.assists.to_string()
        );
        stats_messages.push(sub_message);
    }
    return Some(format!("({})", stats_messages.join(", ")));
}

And then I wrote tests to make sure this works properly.

#[test]
fn it_crafts_no_message_if_no_highlighted_players_gain_stats() {
    let highlights: Vec<String> = vec![String::from("Crosby")];
    let goal: Goal = Goal {
        scorer: String::from("Malkin"),
        assists: vec![String::from("Letang"), String::from("Karlsson")],
        minute: 21,
        special: false,
        team: String::from("Pittsburg"),
    };

    let expected: Option<String> = None;
    let actual: Option<String> = craft_stats_message(&vec![goal], &highlights);

    assert_eq!(actual, expected);
}

#[test]
fn it_crafts_good_message_if_player_scored() {
    let highlights: Vec<String> = vec![String::from("Crosby")];
    let goal: Goal = Goal {
        scorer: String::from("Crosby"),
        assists: vec![String::from("Letang"), String::from("Karlsson")],
        minute: 21,
        special: false,
        team: String::from("Pittsburg"),
    };

    let expected: Option<String> = Some(String::from("(Crosby 1+0)"));
    let actual: Option<String> = craft_stats_message(&vec![goal], &highlights);

    assert_eq!(actual, expected);
}

// + more

Release Notes Driven Development

If you look at the commit log of this changeset, you notice I started with the release notes, then wrote documentation and only after that, started writing the implementation.

I really like the approach of starting from the usage and documentation side of things when implementing new features. It shifts the focus into what I believe to be most important parts of the software development: how it will be used and how it looks like to the end user.

Instead of letting the implementation take the lead and then maybe writing some docs as an afterthought, I want to put my best energy towards that.

So I ended up writing the release notes for this version 1.3.0 and thought about how this feature will look like for the end user. Then I wrote the documentation on how to actually use it.

This helped me solidify a good user interface, go through some questions that came up with myself about certain corner cases and design decisions. That in turn helped me focus on the implementation when I already knew how I wanted it to work out and had in this case all the questions answered already.

The best part is that after I’m done with implementation, testing and multiple rounds of iterations, I already have everything else ready. I find myself not being at my best at the end of the coding part, often eager to move to the next thing or just get it out the door which leads to documentation being sometimes overlooked or not as polished as it should be.

Comments

Comment by replying to this post in Mastodon.

Loading comments...

Continue discussion in Mastodon »