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.
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(())
}
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
.
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!