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 includeread_to_end,read_to_string,read_exact; -
Write—write(&buf)andflush(); providedwrite_alland thewrite!/writeln!macros; -
BufRead— a reader with an internal buffer, addingread_line,lines()andsplit().
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(())
}
Source: std::process::Command
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.