Prompting the user for input with rprompt and loop

Chris Biscardi
InstructorChris Biscardi
Share this video with your friends

Social Share Links

Send Tweet

Given the potential document_title we now want to ask the user if the title is still what they want to use so that we can write the file to disk. This will involve two paths depending on if we have an existing potential option or not.

We will need to use ask_for_filename in confirm_filename, so we'll write that branch first.

let filename = match document_title {
    Some(raw_title) => {
        // confirm_filename()?
        todo!()
    }
    None => ask_for_filename(),
};

rprompt allows us to ask for a small amount of input from the user (compared to the much larger input possible by passing control to the user's editor).

We'll use rprompt::prompt_reply_stderr to get a response from the user, and .wrap_err to add context to the error if anything goes wrong.

fn ask_for_filename() -> Result<String> {
    rprompt::prompt_reply_stderr(
        "\
Enter filename
> ",
    )
    .wrap_err("Failed to get filename")
}

We'll use the same behavior for the first part of confirm_filename. We'll also need to use our first lifetime to account for the shared reference argument.

match can match against multiple values for the same branch, so we'll take advantage of that to handle branches for Ns and Ys, as well as the default case. If anything goes wrong, such as someone inputting an "a", we'll fall through using loop and ask again until we get a usable answer

fn confirm_filename(raw_title: &str) -> Result<String> {
    loop {
        // prompt defaults to uppercase character in question
        // this is a convention, not a requirement enforced by
        // the code
        let result = rprompt::prompt_reply_stderr(&format!(
            "\
current title: `{}`
Do you want a different title? (y/N): ",
            raw_title,
        ))
        .wrap_err("Failed to get input for y/n question")?;

        match result.as_str() {
            "y" | "Y" => break ask_for_filename(),
            "n" | "N" | "" => {
                // the capital N in the prompt means "default",
                // so we handle "" as input here
                break Ok(slug::slugify(raw_title));
            }
            _ => {
                // ask again because something went wrong
            }
        };
    }
}

While filenames can technically have spaces in them, we're going to slugify our filenames like urls for a few reasons. One is that when starting out, many programmers fail to quote filename arguments in their bash scripts, which results in filenames with spaces being treated as separate arguments. Another is that this is a digital garden CLI, and digital gardens are often stored in git, accessed from multiple file systems, as well as from URLs. While strictly speaking we don't need to slugify our filenames, we will here so as to adhere to a safer set of characters.

cargo add slug

We'll map over the Result from rprompt which allows us to operate on the internal value if it's Ok.

fn ask_for_filename() -> Result<String> {
    rprompt::prompt_reply_stderr(
        "\
Enter filename
> ",
    )
    .wrap_err("Failed to get filename")
    .map(|title| slug::slugify(title))
}

We'll also use slugify in confirm_filename

match result.as_str() {
    "y" | "Y" => break ask_for_filename(),
    "n" | "N" | "" => {
        // the capital N in the prompt means "default",
        // so we handle "" as input here
        break Ok(slug::slugify(raw_title));
    }
    _ => {
        // ask again because something went wrong
    }
};

The fact that confirm_filename and ask_for_filename have the same return type is important because the branches of a match need to return the same type.

Chris Biscardi: [0:00] Given the potential document title, we now want to ask the user if the title is still what they want to use so that we can write the file to disk.

[0:09] This will involve two paths, depending on if we have an existing potential option and document title or not. We'll match on document title. If we have an option, we're going to ask the user to confirm the file name.

[0:25] For now, we'll leave that as todo. We'll move on to askForFileName, which is the case where we don't have a document title, which means the user hasn't passed one in and they didn't type enough in the markdown file to let us use our heuristic to find a title.

[0:42] We're going to start with askForFileName as opposed to confirmFileName, because askForFileName will be used inside of the confirmFileName function.

[0:50] We're going to add another package called rprompt. Note that our askForFileName takes no arguments but returns a result of string. We'll use rprompt and specifically prompt_reply_stderr to ask for a file name from the user.

[1:07] Note that this is a two-line query. First line we'll have "Enter file name." The second line, we'll just have an arrow. I've used a backslash to give us a new line so that both of these are on the same horizontal column in our source code.

[1:24] Note that rprompt returns a result so we don't need to do much here, but we do want to add a little bit of context just in case something goes wrong. This again uses the wrap error trait I'm calling error and we say, "Failed to get file name."

[1:41] Next, we'll implement confirmFileName. Again, we create a new function. This time we want to pass in the raw title, which will be a string slice. If we go back up to confirmFileName, we can see that we have a raw title inside of this sum which we've destructured out of.

[1:57] We'll edit as an argument here. Cargo check does tell us there's a problem with this because we've defined our confirmFileName function to take a string slice, but we've passed in a string. We can see the string because Rust analyzer tells us here.

[2:13] Cargo check tells us to consider borrowing here which is what we'll do. We can turn a String into a string slice by borrowing. Inside of confirmFileName, we'll use control flow construct called loop.

[2:30] Loop will infinitely loop until we break. When we break, we're allowed to give it a value to break with which will end up in this case because loop is the only expression here and we haven't included a semicolon. The break value will be result of string.

[2:48] Again, we use a similar pattern with rprompt prompt_reply_stderr. In this case, we'll use the format macro to ask the user if the current title which we pass in using this display formatter is good enough for them by asking, "Do you want a different title?" "Yes or No."

[3:10] We've capitalized N because N is the default value if they just hit Enter. This is the convention. It really isn't enforced anywhere, but it's good enough for us for now. Remember that rprompt prompt_reply_stderr returns us a result so we could wrap that result in an error to give it more context. In this case, we'll let the user know that we failed to get input for a yes or no question.

[3:36] Given the result, we'll match on the result as str. I use as str here because string literals are string slices in Rust. This saves us a little bit of typing because we don't have to do string.from to get a String or an own string.

[3:56] Inside of a match, we can use multiple patterns. In this case, we're checking to see if it is y or Y. If it is, then we'll ask for the file name and break with the result. If it's n, N or the user hasn't given us any input, we'll break with an OK the slugified title value.

[4:22] If neither of those occur, we'll loop through again and ask again. The break syntax might be a little bit confusing if you haven't seen it before. Remember that in Rust, most things are expressions and expressions return values.

[4:38] In this case what we're doing is saying we're going to break out of this loop and return the value that is returned from this function call. The return value of loop becomes the return value of this function, which, because it's the only expression in confirmFileName, also becomes the return value of the entire function. A similar thing is happening down on line 79 where we break with an OK value. OK is a result variant.

[5:06] I also have slug slugifying here which is a package we haven't added yet. The reason we have this is because file names can technically have spaces in them.

[5:15] We're going to slugify our file names like URLs for a few reasons. One is that when starting out, many programmers fail to quote file name arguments in their batch scripts which results in file names with spaces being treated as separate arguments, which can be confusing and hard to diagnose.

[5:33] Since this is a CLI tool, we want to make sure that this as palatable as possible especially to new programmers. Another reason is that this is a digital garden CLI and digital gardens are often stored in git, accessed from multiple different file systems as well as from URLs.

[5:51] While strictly speaking we don't need to slugify our file names especially since most modern systems can handle spaces correctly, we will here so as to adhere to a safer set of characters. We have to add this slug package.

[6:06] In confirmFileName, we've already used it, but in askForFileName, we still have to. Remember map that we used before can be used to map over the value inside of a structure. In this case, we're mapping over a result.

[6:24] If it's OK, then we take the value inside of the OK variant and we slugify it returning the OK variant with a new value inside of it. If it's an error, map won't touch it because map only operates on OK for some values. That is values inside of structures that represent success.

[6:46] The fact that askForFileName and confirmFileName both results Strings is no accident. The match on the document title, the return values for confirmFileName and askForFileName in each branch of the match must have the same type to be valid. If they had different types, this match would fail to compile.

[7:09] We'll add in a debug of contents and file name just to make sure everything works. We can see that if we have a title, we get current title heading here, "Do you want a different title?" If we say no, the program will end so we'll say yes which kicks us into the askForFileName cycle.

[7:31] The program gives us "Enter file name" and the opportunity to enter one. Contents of the file include the heading which is what we pulled up before for the original title. The user however told us that they wanted a different name which is "my awesome file," which we can see here. This is how we can interact with the user for quick back and forth.