Rust: Text I/O

Home · Blog

14 March 2023

After getting into some of the fundamentals of Rust with traits, generics, and lifetimes, we’ll take a look at something more pedestrian today: reading and writing text.

Text input

We’ll start with reading a file line-by-line:

use std::fs::File;
use std::io;
use std::io::{BufRead, BufReader};

fn main() -> io::Result<()> {
    let file = File::open("foo.txt")?;   // open for reading
    let reader = BufReader::new(file);
    for line in reader.lines() {
        println!("read from file: {}", line?);
    }
    Ok(())
}

The tools we use are File::open, which opens a file read-only, BufReader, which provides buffering and methods to read data one line at a time, and the lines method, which returns an iterator over the lines in the file.

If you’re paying close attention you may have noticed that I’m importing the std::io::BufRead trait, but then it doesn’t appear anywhere else in the code. The reason is a rule in Rust that can be suprising to those new to the language (it was for me, anyways): if you want to use a method defined in a trait, you have to import the trait, not just the concrete type that implements the method. In this case the lines method is defined in BufRead.

In the standard library, functions that do input or output usually return std::io::Result to enable proper error handling in case something goes wrong. In the example, I’ve defined main to return io::Result<()> to simplify error handling (see Rust: Functions). Notice the two question marks in the code pointing out where something could go wrong: opening the file and reading a line.

To read a whole file in one go, you can use the read_to_string method, defined in the Read trait:

use std::fs::File;
use std::io;
use std::io::Read;

fn main() -> io::Result<()> {
    let mut file = File::open("foo.txt")?;
    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?;
    println!("whole file:\n{}", buffer);
    Ok(())
}

If you want to read from standard input instead of a file, you can use the stdin function. It returns an Stdin object, which like File implement the Read trait. It also has a lines method that lets us iterate over input lines without using a BufReader:

use std::io;

fn main() -> io::Result<()> {
    let stdin = io::stdin();
    for line in stdin.lines() {
        println!("read from stdin: {}", line?);
    }
    Ok(())
}

Text output

If you want to write a text file, open it with File::create, then use the write_all method to write a string, or the write! and writeln! macros for formatted output. This example shows both:

use std::fs::File;
use std::io;
use std::io::Write;

fn main() -> io::Result<()> {
    let mut file = File::create("foo.txt")?;         // open for writing
    file.write_all("Hello, world!\n".as_bytes())?;   // write string
    writeln!(file, "{} plus {} is {}", 2, 2, 4)?;    // formatted output
    Ok(())
}

File::create creates the file if it doesn’t exist, and truncates it (deletes its content) it if does. If that’s not what you want, take a look at File::options instead.

I’m using write_all here instead of the write method because write doesn’t guarantee that it writes the whole buffer. If you use write, you need to check its return value and call it repeatedly until all data is written.

To write to standard out you can obviously just use print! and println!, but sometimes it’s useful to treat standard out like a file. For those cases, std::io::stdout returns a file-like object:

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

fn main() -> io::Result<()> {
    let mut file = std::io::stdout();
    file.write_all("Hello, world!\n".as_bytes())?;   // write string
    writeln!(file, "{} plus {} is {}", 2, 2, 4)?;    // formatted output
    Ok(())
}

To use standard error instead, replace println! with eprintln! and std::io::stdout with std::io::stderr.

Error handling

So far we haven’t paid much attention to error handling: if you define main to return a Result, you can just return an error. That’s fine for sample code, but for a real project you’d probably want real error handling. Here’s another example reading input line-by-line, this time with more explicit error handling:

use std::io;

fn main() {
    for line in io::stdin().lines() {
        match line {
            Err(e) => eprintln!("error reading line: {}", e),
            Ok(line) => println!("read line: {}", line),
        }
    }
}

Let’s try it out by sending three lines of text on the command line:

$ printf 'Hello!\n¡Buenos días!\nBonjour!' |
> cargo run
read line: Hello!
read line: ¡Buenos días!
read line: Bonjour!

As expected, we hit the Ok case three times.

How do we test the Err case? For text input, there’s an easy way: text input is always expected to be valid UTF-8. We can use the iconv utility to convert the input from UTF-8 to the old ISO-8859-1 encoding, then feed that into the program. Here’s what happens:

$ printf 'Hello!\n¡Buenos días!\nBonjour!' |
> iconv -f UTF-8 -t ISO-8859-1 |
> cargo run
read line: Hello!
error reading line: stream did not contain valid UTF-8
read line: Bonjour!

The first and last line are the same in both encodings, so they’re processed without issues, but when the ¡ and í characters are converted to ISO-8859-1 we end up with bytes that aren’t valid UTF-8 at all, and the iterator returns an Err value.

Here’s code that writes a line to a file with very explicit error handling:

use std::fs::File;
use std::io::Write;

fn main() {
    match File::create("foo.txt") {
        Err(e) => {
            eprintln!("error opening file: {}", e);
        }
        Ok(mut file) => {
            if let Err(e) = file.write_all("Hello!\n".as_bytes()) {
                eprintln!("error writing file: {}", e);
            }
        }
    }
}

If we just run it normally, it writes the file as expected:

$ cargo run
$ cat foo.txt 
Hello!

Let’s try to hit both Err cases to verify that they work the way we’d expect. We have a lot of options here -- many things can go wrong when you’re writing to a file! A simple one is when the user doesn’t have permission to open the file. On Linux or Mac OS we can use the chmod utility to change permissions:

$ touch foo.txt && chmod 000 foo.txt && cargo run
error opening file: Permission denied (os error 13)

That’s the first Err case. To test the second we can use the /dev/full special file, which simulates a full disk. (This works on Linux, not sure about Mac OS.) If we turn foo.txt into a symlink to /dev/full, it’ll also act like it’s on a full disk:

$ ln -s /dev/full foo.txt && cargo run
error writing file: No space left on device (os error 28)

Neat!