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!