I/O, Files, Processes & Environment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. The standard library’s std::io, std::fs, std::path, std::env and std::process modules cover what a command-line program or a service needs to talk to the operating system. This page walks through them and ends with the shelf CLI, modelled on the I/O project in chapter 12 of The Book. Async equivalents live in tokio::fs, tokio::io and tokio::process (see Async Runtimes & Tokio).

Read, Write and BufRead

I/O in Rust is built on three traits:

  • Read — read(&mut buf) fills a byte buffer; provided methods include read_to_end, read_to_string, read_exact;

  • Write — write(&buf) and flush(); provided write_all and the write!/writeln! macros;

  • BufRead — a reader with an internal buffer, adding read_line, lines() and split().

Files, sockets, stdin/stdout, Vec<u8> (as a writer), &[u8] (as a reader) and Cursor<T> implement them, so code written against impl Read/impl Write works with all of them — and tests can use in-memory buffers.

Every syscall-level read/write is expensive, so wrap files and sockets in BufReader and BufWriter when doing many small operations. A BufWriter flushes when dropped, but errors during that implicit flush are ignored: call flush() explicitly when you care.

use std::io::{self, BufRead, BufReader, BufWriter, Cursor, Read, Write};

/// Counts lines that look like ISBNs. Works on any buffered reader: a file, stdin or a byte slice.
fn count_isbns(input: impl BufRead) -> io::Result<usize> {
    let mut n = 0;
    for line in input.lines() {
        let line = line?;
        if line.trim().replace('-', "").len() == 13 {
            n += 1;
        }
    }
    Ok(n)
}

/// Writes a report to any writer: a file, stdout or a Vec<u8> in tests.
fn write_report(mut out: impl Write, titles: &[&str]) -> io::Result<()> {
    for (i, t) in titles.iter().enumerate() {
        writeln!(out, "{:>3} {t}", i + 1)?;
    }
    out.flush()
}

fn main() -> io::Result<()> {
    let data = "978-0-441-01359-3\nnot an isbn\n9780141439518\n";
    println!("{} ISBNs", count_isbns(Cursor::new(data))?);

    let mut buffer = Vec::new();
    write_report(&mut buffer, &["Dune", "Emma"])?;           // &mut Vec<u8> implements Write
    print!("{}", String::from_utf8_lossy(&buffer));

    let stdout = io::stdout().lock();                        // lock once for many writes
    write_report(BufWriter::new(stdout), &["Kindred"])?;

    let mut first_bytes = [0u8; 3];
    BufReader::new(data.as_bytes()).read_exact(&mut first_bytes)?;
    println!("{}", String::from_utf8_lossy(&first_bytes));  // 978
    Ok(())
}

Source: std::io

io::stdout() is line-buffered and locks a global mutex on every print!; when printing in a loop, take the lock once (io::stdout().lock()) and wrap it in a BufWriter. Write diagnostics to stderr (eprintln!) so that stdout stays clean for program output, which matters for CLIs used in pipelines.

Files

use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;

fn main() -> io::Result<()> {
    let dir = std::env::temp_dir().join("shelf-io-demo");
    fs::create_dir_all(&dir)?;                                 // like mkdir -p
    let path = dir.join("books.csv");

    fs::write(&path, "isbn,title\n9780441013593,Dune\n")?;     // create or truncate, write everything
    let text = fs::read_to_string(&path)?;                      // read everything (UTF-8)
    println!("{} bytes, {} lines", text.len(), text.lines().count());

    let mut log = OpenOptions::new().create(true).append(true).open(dir.join("loans.log"))?;
    writeln!(log, "lent 9780441013593 to alice")?;

    let file = File::open(&path)?;                             // read-only
    for line in BufReader::new(file).lines().skip(1) {          // stream line by line
        println!("row: {}", line?);
    }

    let meta = fs::metadata(&path)?;
    println!("is_file={} len={} readonly={}", meta.is_file(), meta.len(), meta.permissions().readonly());

    for entry in fs::read_dir(&dir)? {
        let entry = entry?;
        println!("{}", entry.file_name().to_string_lossy());
    }

    match File::open(Path::new("/definitely/missing.csv")) {
        Err(e) if e.kind() == io::ErrorKind::NotFound => println!("not found, as expected"),
        Err(e) => return Err(e),
        Ok(_) => {}
    }
    fs::remove_dir_all(&dir)
}

Source: std::fs

OpenOptions builds any open mode: read, write, append, truncate, create, create_new (fail if it exists — the safe way to avoid clobbering). Errors are io::Error values with a kind() (NotFound, PermissionDenied, AlreadyExists, …​) for matching. For atomic replacement of a file, write to a temporary file in the same directory and fs::rename it over the target.

Locking and timestamps

File::lock, lock_shared, try_lock and unlock (stable since 1.89) take advisory whole-file locks — flock on Unix, LockFileEx on Windows — so two processes do not write the same catalogue file at once. fs::set_times and File::set_times with FileTimes (fs::set_times is stable since 1.99) change the access and modification times:

use std::fs::{self, File, FileTimes};
use std::time::{Duration, SystemTime};

fn main() -> std::io::Result<()> {
    let path = std::env::temp_dir().join("shelf-catalog.lock-demo");
    let file = File::create(&path)?;
    file.lock()?;                                       // 1.89: exclusive advisory lock, blocks if held elsewhere
    // ... write the catalogue safely ...
    file.unlock()?;                                     // also released when the file is closed

    let a_day_ago = SystemTime::now() - Duration::from_secs(24 * 3600);
    fs::set_times(&path, FileTimes::new().set_modified(a_day_ago))?; // 1.99
    println!("{:?}", fs::metadata(&path)?.modified()?.elapsed().map(|d| d.as_secs() / 3600));
    fs::remove_file(&path)
}

Source: std: File::lock

Path and PathBuf

Path (borrowed) and PathBuf (owned) are to OsStr/OsString what str/String are to bytes: they hold operating-system strings, which need not be UTF-8, and add path operations. Build paths with join rather than string formatting, so separators are right on every platform:

use std::path::{Path, PathBuf};

fn main() {
    let base = Path::new("/srv/shelf");
    let cover: PathBuf = base.join("covers").join("dune.jpg");
    println!("{}", cover.display());                          // /srv/shelf/covers/dune.jpg
    println!("{:?} {:?} {:?}", cover.file_stem(), cover.extension(), cover.parent());
    println!("{}", cover.starts_with(base));                  // true
    let thumb = cover.with_extension("webp");
    let relative = cover.strip_prefix(base).expect("under base");
    println!("{} {}", thumb.display(), relative.display());   // ... covers/dune.jpg
    for component in relative.components() {
        print!("[{}] ", component.as_os_str().to_string_lossy());
    }
    println!();
}

Source: std::path

Functions that take paths should accept impl AsRef<Path>, so callers can pass &str, String, PathBuf or &Path. Never join untrusted input onto a base directory without checking: base.join("../../etc/passwd") escapes it, and join with an absolute path replaces the base entirely. Canonicalise and check starts_with, as the axum file-serving pages do.

Command-line arguments

std::env::args() yields the program’s arguments as Strings (panicking on non-UTF-8; args_os() yields OsStrings). The first item is the program name.

fn main() {
    let args: Vec<String> = std::env::args().collect();
    match args.get(1).map(String::as_str) {
        Some("search") => println!("searching for {:?}", args.get(2)),
        Some(cmd) => eprintln!("unknown command {cmd}"),
        None => eprintln!("usage: {} <search|export> [query]", args[0]),
    }
}

For anything beyond a couple of positional arguments, use clap, the ecosystem’s standard argument parser: derive a struct, and clap generates parsing, validation, --help, subcommands and shell completions. The CLI pages of this site cover clap and terminal UIs with Ratatui in depth: CLI Frameworks in Go & Rust.

Environment variables

use std::env;

fn main() {
    let data_dir = env::var("SHELF_DATA_DIR").unwrap_or_else(|_| "./data".into()); // missing or non-UTF-8 -> default
    let max_loans: u32 = env::var("SHELF_MAX_LOANS").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
    println!("{data_dir} {max_loans}");
    for (key, _) in env::vars().filter(|(k, _)| k.starts_with("SHELF_")) {
        println!("set: {key}");
    }
    println!("{:?} {:?}", env::current_dir().is_ok(), env::home_dir().is_some());
}

Source: std::env

env::var returns Err both when the variable is missing and when it is not valid Unicode; var_os returns Option<OsString>. In the 2024 edition, env::set_var and env::remove_var are unsafe: changing the environment while other threads read it is a data race in the C library on most platforms. Read configuration once at start-up and pass it around; to give a child process a different environment, use Command::env (below), which is safe. See Unsafe Rust.

Running processes

std::process::Command builds and runs a child process. output() waits and captures everything, status() waits and inherits stdio, and spawn() returns a Child you can feed and read while it runs:

use std::io::{Read, Write};
use std::process::{Command, Stdio};

fn main() -> std::io::Result<()> {
    let out = Command::new("sort").arg("-r").env("LC_ALL", "C").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()
        .and_then(|mut child| {
            child.stdin.take().expect("piped").write_all(b"Dune\nEmma\nKindred\n")?; // stdin closed when dropped
            child.wait_with_output()
        })?;
    println!("{} -> {:?}", out.status, String::from_utf8_lossy(&out.stdout));

    let status = Command::new("sh").args(["-c", "exit 3"]).status()?;
    println!("exit code {:?}, success {}", status.code(), status.success()); // Some(3) false

    // io::pipe (1.87): an anonymous pipe, e.g. to merge a child's stdout and stderr.
    let (mut reader, writer) = std::io::pipe()?;
    let mut child = Command::new("sh")
        .args(["-c", "echo out; echo err 1>&2"])
        .stdout(writer.try_clone()?)
        .stderr(writer)
        .spawn()?;
    drop(child.stdin.take());
    let mut merged = String::new();
    child.wait()?;
    drop(child);                       // close our copies so the reader sees end-of-file
    reader.read_to_string(&mut merged)?;
    println!("{merged:?}");
    Ok(())
}

Command never goes through a shell unless you call one: arguments are passed as an array, so there is no shell injection from arg(user_input) — but sh -c with interpolated input reintroduces it. std::process::exit(code) ends the process immediately without running destructors (buffered writers are not flushed); returning an ExitCode from main is cleaner.

Exit codes

main can return (), Result<(), E: Debug> (exit code 1 on Err), std::process::ExitCode, or any type implementing Termination. Use ExitCode::from(n) for specific codes: CLIs conventionally use 0 for success, 1 for general errors and 2 for usage errors.

The shelf CLI end to end

The shelf binary combines these pieces: arguments select a subcommand, the catalogue is loaded from a JSON file whose location comes from the environment, output goes to stdout, diagnostics to stderr, and the exit code reports failure. This compact version uses only the standard library and shelf-core:

use std::io::{self, BufWriter, Write};
use std::process::ExitCode;

use shelf_core::{Author, Book, Catalog, InMemoryCatalog, Isbn};

fn load_catalog() -> InMemoryCatalog {
    // A real implementation reads $SHELF_DATA_DIR/catalog.json (see the serde page).
    let mut c = InMemoryCatalog::new();
    for (isbn, title, year) in [("9780441013593", "Dune", 1965), ("9780141439518", "Emma", 1815)] {
        let isbn = Isbn::parse(isbn).expect("valid fixture");
        c.add(Book::new(isbn, title, vec![Author::new("unknown")], year)).expect("unique fixture");
    }
    c
}

fn run(args: &[String]) -> Result<(), String> {
    let catalog = load_catalog();
    let mut out = BufWriter::new(io::stdout().lock());
    match args {
        [cmd, query] if cmd == "search" => {
            for book in catalog.search(query) {
                writeln!(out, "{}\t{}\t{}", book.isbn, book.title, book.year).map_err(|e| e.to_string())?;
            }
        }
        [cmd] if cmd == "count" => writeln!(out, "{}", catalog.len()).map_err(|e| e.to_string())?,
        _ => return Err("usage: shelf <search QUERY | count>".into()),
    }
    out.flush().map_err(|e| e.to_string())
}

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match run(&args) {
        Ok(()) => ExitCode::SUCCESS,
        Err(msg) => {
            eprintln!("shelf: {msg}");
            ExitCode::from(2)
        }
    }
}

The full version on this site’s serde page reads and writes JSON (see Serialization with serde); errors are handled with anyhow as shown on Error Handling.