Frontend Labs
⋯

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.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36