site logo
github logotwitter logolinkedin logo

Looping through user inputs in terminal

July 03, 2021

Capturing user inputs in the terminal has some little nuances to it that can give you a hard time if you don’t know about them.

It’s been quite some time since I coded in a lower level language than JavaScript. Distributed systems recently piqued my interest thus I decided to pick-up a lower-level compiled language. I chose Rust which I knew nothing about. Inspired by @swyx’s “learn in public” mantra I decided to document my journey learning Rust.

I faced three issues while developing a CLI where I needed to repeatedly ask for the user input until it matches specific keywords. Here is the initial code I wrote but it doesn’t work as expected:

use std::io;

fn main() {
    let mut choice = String::new();

    while choice.as_str() != "q" {
        println!("What do you want to do?");
        println!("(p)rint hello");
        println!("(q)uit the program");

        print!("Your choice: ");

        io::stdin()
            .read_line(&mut choice)
            .expect("Cannot read user input");

        println!();

        if choice.as_str() == "p" {
            println!("Hello!");
            println!();
        };
    }
}

Notes: if you don’t understand why we need to do “choice.as_str()” to compare String with string slices, I encourage you to read another of my articles.

Did you find all three problems with the above code? 🤓

If yes, then wonderful I don’t have anything else to teach you here otherwise you can keep reading. Of course you can still keep reading to compare your foundings with mine.

Flush it

If you ran the above code you might have noticed that the print!("Your choice: ") call is not displayed when you’d expect it to be:

CLI display without flushing the terminal

It should be displayed right after the line: (q)uit the program however it is displayed after the user answer. This is due to the fact that stdout is line-buffered by default so the content of the current line won’t be displayed until a line return character is encountered. To bypass this behavior we need to call std::io::stdout().flush(). I learned it thanks to this StackOverflow answer. The code is now:

use std::io;
use std::io::Write;
fn main() {
    let mut choice = String::new();

    while choice.as_str() != "q" {
        println!("What do you want to do?");
        println!("(p)rint hello");
        println!("(q)uit the program");

        print!("Your choice: ");
        io::stdout().flush().expect("Cannot flush stdout");
        io::stdin()
            .read_line(&mut choice)
            .expect("Cannot read user input");

        println!();

        if choice.as_str() == "p" {
            println!("Hello!");
            println!();
        };
    }
}

Two things changed here:

  1. We called flush after the print! call that way the user answer is displayed right after it. I used expect here because flush returns a Result enum value. We need to handle it in case we got an error. expect was just the convenient way of handling errors in this small example.
  2. We also needed to bring into scope the trait that flush relies upon to do its job: Write.

Great! Now it displays properly but it ain’t over yet 🥵.

Make a clean slate

If you played with the program already, you might have noticed that no matter what choice you make the program doesn’t take it into account. See for yourself:

cli output where no choices are taken into account

From here it’s a bit hard to see what’s going on so let’s display choice content. You need to add this:

        // --snip--

        println!("You selected: {}", choice);
        if choice.as_str() == "p" {
            println!("Hello!");
            println!();
        };

        // --snip--

Here is what we can see:

cli output with choice content displayed

choice contains every input we provided to the program… I wasn’t expecting that either but seems logical. read_line() actually appends every choice you type to choice whereas I was expecting it to replace its content every time. Do I hear RTFM! in the back? Well it’s definitely written in the function documentation. 😅

Nevermind friend! It happens that there is a function on the String type that will be quite handy in our case: clear. This function truncates the String, removing all contents. Let’s use it right after we enter the while loop:

    while choice.as_str() != "q" {
        choice.clear();
        // --snip--
    }

Let’s give it a try:

cli output after clearing the choice variable

Hooray! choice now has only the content we’re interested in… not so quite yet. Actually there is still an intruder 🐱‍👤. Did you find it yet?

Trim the edges off!

The read_line function does exactly what it’s intended for: reading the user inputs until the Enter key is pressed. The thing is that it also captures the actual line return character and saves it inside choice. The while condition always evaluates to true: "q\n" != "q". On the other hand the if condition always evaluates to false: "p\n" == "p".

The fix is simple. Use the String.trim() function instead of as_str() in the conditions. We don’t need to call again as_str() because trim() returns a &str.

Let’s give it a try:

cli output with everything fixed

We’re done! Our little program works properly now. 🤝

The final program is:

use std::io;
use std::io::Write;

fn main() {
    let mut choice = String::new();

    while choice.trim() != "q" {
        choice.clear();

        println!("What do you want to do?");
        println!("(p)rint hello");
        println!("(q)uit the program");

        print!("Your choice: ");
        io::stdout().flush().expect("Cannot flush stdout");

        io::stdin()
            .read_line(&mut choice)
            .expect("Cannot read user input");

        println!();

        println!("You selected: {}", choice);
        if choice.trim() == "p" {
            println!("Hello!");
            println!();
        };
    }
}

Conclusion

We learned that flush is needed when you want to display the content of the current line without waiting for an actual line ending character. We saw that read_line appends the user inputs into the variable it’s passed rather than replacing its content. Finally we discovered that we need to clear any whitespace and line ending character from the user inputs in order to properly compare it with string slices.

That’s it for today. I hope it helped!


Written by Jonas who lives in Lyon, France and he's passionate about Open Source and web development in general. Feel free to ping him on Twitter.