C tools and libraries quickly reach for interfaces like these:
FILE *fp = fopen(path, "rb");
fread(buf, 1, sizeof(buf), fp);
fclose(fp);
DIR *dir = opendir(path);
system("gzip file.log");
getenv("HOME");
Rust’s standard library splits the same area into a few modules:
std::fs -> convenient file and directory operations
std::io -> Read / Write / BufRead traits
std::env -> arguments, environment variables, current directory
std::process -> child processes, exit status, captured output
This article focuses on the first replacement layer. Ownership and automatic cleanup are covered by the Drop article.
Reading a Whole File
C code often opens a file, then loops over fread:
FILE *fp = fopen(path, "rb");
if (!fp) {
return -1;
}
size_t n = fread(buf, 1, sizeof(buf), fp);
fclose(fp);
If Rust only needs the complete file, use:
let data = std::fs::read(path)?;
std::fs::read returns Result<Vec<u8>, std::io::Error>. Success gives bytes; failure gives an error.
For text:
let text = std::fs::read_to_string(path)?;
This checks that the content is valid UTF-8. Use read for binary data and read_to_string for text. Do not put every file into the C char * bucket.
Streaming Reads Use File and Read
Large files and stream processing should not read everything into memory. Open a file:
use std::fs::File;
use std::io::{self, Read};
fn checksum(path: &str) -> io::Result<u32> {
let mut file = File::open(path)?;
let mut buf = [0_u8; 4096];
let mut sum = 0_u32;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
for byte in &buf[..n] {
sum += *byte as u32;
}
}
Ok(sum)
}
The read method comes from std::io::Read. It is close to C read or fread: it writes into the caller’s buffer and returns the number of bytes read.
The difference is that errors use Result, and the File is closed automatically when it leaves scope.
Writing Files
In C:
FILE *fp = fopen(path, "wb");
fwrite(data, 1, len, fp);
fclose(fp);
Rust can write a complete file:
std::fs::write(path, data)?;
For incremental writing:
use std::fs::File;
use std::io::{self, Write};
fn write_report(path: &str, lines: &[String]) -> io::Result<()> {
let mut file = File::create(path)?;
for line in lines {
writeln!(file, "{line}")?;
}
Ok(())
}
Write provides write, write_all, and flush. The writeln! macro can write to any target implementing Write.
Buffered I/O Uses BufReader and BufWriter
C FILE * is buffered. Rust File is closer to a file handle. For line-oriented reads or many small operations, add buffering explicitly:
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn print_lines(path: &str) -> io::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
println!("{line}");
}
Ok(())
}
For writing:
use std::fs::File;
use std::io::{self, BufWriter, Write};
fn save_lines(path: &str, lines: &[String]) -> io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
for line in lines {
writeln!(writer, "{line}")?;
}
writer.flush()?;
Ok(())
}
First rule: for line reads or frequent small reads and writes, use BufReader or BufWriter.
Directory Operations
C code often uses opendir and readdir:
DIR *dir = opendir(path);
struct dirent *ent;
while ((ent = readdir(dir)) != NULL) {
puts(ent->d_name);
}
closedir(dir);
Rust directory iteration:
use std::fs;
use std::io;
fn list_dir(path: &str) -> io::Result<()> {
for entry in fs::read_dir(path)? {
let entry = entry?;
println!("{}", entry.path().display());
}
Ok(())
}
read_dir(path)? only means opening the directory succeeded. Each entry may still fail, so the loop has another entry?.
Common directory operations:
std::fs::create_dir("logs")?;
std::fs::create_dir_all("logs/app/2026")?;
std::fs::remove_file("old.log")?;
std::fs::remove_dir("empty-dir")?;
std::fs::remove_dir_all("tree")?;
Recursive deletion should be treated carefully, just as in C. The target path and error path must be clear.
Paths Use Path and PathBuf
C often treats paths as const char *. Rust has path types:
std::path::Path
std::path::PathBuf
A function can accept:
use std::path::Path;
fn load_config(path: &Path) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
Call it with:
let text = load_config(Path::new("config.toml"))?;
Join paths with:
let path = std::path::PathBuf::from("logs").join("app.log");
Prefer PathBuf::join over string concatenation. It says this is a filesystem path, not just text.
Environment Variables and Current Directory
C uses getenv:
const char *home = getenv("HOME");
Rust:
let home = std::env::var("HOME");
It returns Result<String, VarError>. The variable may be missing, and it may not be valid Unicode.
To preserve OS string semantics:
let path = std::env::var_os("PATH");
Current directory:
let cwd = std::env::current_dir()?;
std::env::set_current_dir("/tmp")?;
The first article used command-line arguments:
let args: Vec<String> = std::env::args().collect();
If arguments may contain non-Unicode paths, use args_os() later.
Starting Child Processes
C code may use system:
int rc = system("gzip file.log");
Rust should not default to building shell strings. Use std::process::Command:
use std::process::Command;
let status = Command::new("gzip")
.arg("file.log")
.status()?;
if !status.success() {
eprintln!("gzip failed: {status}");
}
To capture stdout:
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
println!("{text}");
}
Command::new("prog").arg(value) is safer than concatenating "prog value", and it matches the argument-array model more closely.
What to Keep
The first mapping from C library calls to Rust standard library modules:
fopen/fread whole file -> std::fs::read / read_to_string
fread loop -> File + Read
fwrite -> File + Write / std::fs::write
FILE * buffering -> BufReader / BufWriter
opendir/readdir -> std::fs::read_dir
getenv -> std::env::var / var_os
cwd -> std::env::current_dir / set_current_dir
system -> std::process::Command
return code / errno -> Result / io::Error / ExitStatus
Rust does not hide these capabilities. It makes the boundaries more explicit: bytes versus text, string versus Path, returned error versus process exit status, and argument array versus shell command line.