24. I/O, files, processes, and application errors
24.1 Read, Write, BufRead, and buffering
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
fn main() -> io::Result<()> {
let path = std::env::temp_dir().join("rust-guide-io.txt");
// Writes: wrap the File in a BufWriter, or every write! is one write(2) syscall.
{
let mut out = BufWriter::new(File::create(&path)?);
for i in 0..1000 {
writeln!(out, "line {i}")?;
}
out.flush()?; // BufWriter flushes on drop too, but drop cannot report errors. Flush explicitly.
}
// Reads: BufReader + lines() streams the file; memory use stays flat for any file size.
let reader = BufReader::new(File::open(&path)?);
let n = reader.lines().filter(|l| l.as_ref().is_ok_and(|l| l.ends_with('7'))).count();
println!("{n} lines end in 7");
// Whole-file helpers for small files:
let text = std::fs::read_to_string(&path)?;
println!("{} bytes", text.len());
// stdout is LINE-buffered and behind a lock; lock it once for bulk output.
let stdout = io::stdout();
let mut lock = stdout.lock();
writeln!(lock, "done")?;
std::fs::remove_file(&path)
}
War story. A log-processing tool written in Rust was slower than the
Python script it replaced. It used println! in a loop over 50 million
lines. Each println! takes the stdout lock, and when stdout is a pipe, it
issues a write syscall for every line. Wrapping stdout().lock() in a
BufWriter made it about 30 times faster. Rust does what you write, and
buffering is something you have to ask for. The
C guide §12.1
describes the opposite surprise: C's stdio buffers by default, so output
sometimes appears late.
24.2 Paths and OsStr
File names on Unix are arbitrary bytes, and on Windows they are UTF-16.
Neither is guaranteed to be valid UTF-8, so Path/PathBuf and
OsStr/OsString exist. Use path.display() to print a path, and
path.to_str() (which returns Option<&str>) when you need it as UTF-8.
24.3 Running processes
use std::process::{Command, Stdio};
fn main() -> std::io::Result<()> {
// Arguments are passed as an argv ARRAY, not through a shell: no shell injection.
let out = Command::new("ls").arg("-1").arg("/").output()?;
println!("exit={} first entries: {:?}",
out.status, String::from_utf8_lossy(&out.stdout).lines().take(3).collect::<Vec<_>>());
// Pipe data into a child process's stdin:
let mut child = Command::new("wc").arg("-l").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
{
use std::io::Write;
let mut stdin = child.stdin.take().expect("stdin was piped");
stdin.write_all(b"a\nb\nc\n")?;
} // dropping stdin closes the pipe, so wc sees EOF
let result = child.wait_with_output()?;
println!("wc says {}", String::from_utf8_lossy(&result.stdout).trim());
Ok(())
}
Command never runs a shell unless you ask for one with
Command::new("sh").arg("-c"). Do that only with strings you fully control.
This is the safe default against OS command injection (see
Security from Zero Ch 42).
Ch 40 covers exit codes and signals in depth.