70 %

Toast is in Beta!Learn more →

Chris Biscardi

Using Rust to let a user use their favorite editor to edit a message in a CLI app

In a CLI, what user experience do we want for the act of writing longer form text?

We have a few options, including letting users write directly in the console or giving them a prepared file that they can then open and write in.

Writing directly in the console doesn't give a user access to their text editor of choice. Which means lacking autocomplete, syntax highlighting, snippets, and other functionality.

Creating a file and handling the path back to the user doesn't quite work for us either, as that reduces the utility of the our CLI to the equivalent of running touch filename.md (and might not even be what we want to use the data for!).

So for our use case, we'd ideally like to open the user's preferred editing environment, whatever that happens to be, then wait for them to be done with the file before continuing.

Since we're working with a CLI tool, we can safely assume that our users either know how to, or are willingly to learn how to, set environment variables. This means we can use the EDITOR variable to select an editor to use. This is the same way git commit works.

In Rust, there is a crate that handles not only EDITOR, but also various fallbacks per-platform. We'll take advantage of edit to call out to the user's choice of editor.

The quick usage of edit allows us to call the edit::edit function, and get the user's data.

rust
pub fn write(garden_path: PathBuf, title: Option<String>) -> Result<()> {
dbg!(garden_path, title);
let template = "# ";
let content_from_user = edit::edit(template).wrap_err("unable to read writing")?;
dbg!(content_from_user);
todo!()
}

This results in a filename that looks like: .tmpBy0Yun "somewhere else" on the filesystem, in a location the user would never reasonably find it. Ideally, if anything went wrong, the user would be able to take a look at the in-progress tempfile they were just working on, which should be in an easily discoverable place like the garden path.

We don't want to lose a user's work.

Additionally, the tempfile doesn't have a file extension, which means that the user's editor will be less likely to recognize it as a markdown file, so we want to add .md to the filepath.

Letting the user write

rust
use color_eyre::{eyre::WrapErr, Result};
use edit::{edit_file, Builder};
use std::io::{Read, Write};
use std::path::PathBuf;
const TEMPLATE: &[u8; 2] = b"# ";
pub fn write(garden_path: PathBuf, title: Option<String>) -> Result<()> {
let (mut file, filepath) = Builder::new()
.suffix(".md")
.rand_bytes(5)
.tempfile_in(&garden_path)
.wrap_err("Failed to create wip file")?
.keep()
.wrap_err("Failed to keep tempfile")?;
file.write_all(TEMPLATE)?;
// let the user write whatever they want in their favorite editor
// before returning to the cli and finishing up
edit_file(filepath)?;
// Read the user's changes back from the file into a string
let mut contents = String::new();
file.read_to_string(&mut contents)?;
dbg!(contents);
todo!()
}

We will use the edit::Builder API to generate a random tempfile to let the user write content into. The suffix is going to be .md and the filename will be 5 random bytes. We also put the tempfile in the garden path, which ensures a user will be able to find it if necessary.

wrap_err (which requires the eyre::WrapErr trait in scope) wraps the potential error resulting from these calls with additional context, making the original error the source, and we can keep chaining after that. We keep the tempfile, which would otherwise be deleted when all handles closed, because if anything goes wrong after the user inputs data, we want to make sure we don't lose that data.

After the file is created, we write our template out to the file before passing control to the user. This requires the std::io::Write trait in scope. We use a const for the file template because it won't change. To make the TEMPLATE a const, we also need to give it a type, which is a two element byte array. Change the string to see how the byte array length is checked at compile time.

Since we have access to the file already, we can read the contents back into a string after the user is done editing. This requires having the std::io::Read trait in scope.

And now we've let the user write into a file which will stick around as long as we need it to, and importantly will stick around if any errors happen in the execution of the program, so we lose no user data and can remove the temporary file at the end of the program ourselves if all goes well.