<< Home

Chapter 16: I/O and the System

Many real programs spend most of their time moving bytes in and out of files, sockets, and the console. Rust provides capable synchronous primitives in std, and a thriving asynchronous ecosystem built around tokio. This chapter shows how to work with files and paths, build small networked programs, parse command line options and environment variables, serialize data with serde, and use time, randomness, and operating system specific features safely and clearly.

Files, paths, and directories

Rust’s std::fs and std::path modules provide portable tools for common tasks such as opening files, reading and writing bytes or text, walking directories, and building paths that work across platforms. The types are small and focused which makes error handling straightforward and composable.

Reading and writing with std::fs

You can read an entire file into memory or stream it incrementally. Both approaches are useful; choose by file size and access pattern. The following example shows text read and write with helpful errors.

use std::fs;
use std::io::{self, Write};

fn main() -> io::Result<()> {
  let content = fs::read_to_string("notes.txt")?;
  println!("Read {} chars", content.len());

  let mut f = fs::File::create("out.txt")?;
  f.write_all(b"Hello, file\n")?;
  Ok(())
}
💡 Prefer read_to_string and write for whole-file operations, and buffered readers or writers for large streams. A buffered wrapper reduces syscalls which improves throughput.

Buffered I/O with BufReader and BufWriter

Buffers smooth out small reads and writes. The reader exposes line oriented helpers and the writer batches output before flushing. This keeps code simple while remaining efficient.

use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};

fn copy_lines(src: &str, dst: &str) -> io::Result<()> {
  let f = File::open(src)?;
  let mut out = BufWriter::new(File::create(dst)?);
  for line in BufReader::new(f).lines() {
    let line = line?;
    writeln!(out, "{}*", line)?;
  }
  Ok(())
}

Working with Path and PathBuf

Paths are more than strings. Use Path for borrowed views and PathBuf for owned, mutable paths. Join components with push and use display for printing.

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

fn log_path(base: &Path, name: &str) -> PathBuf {
  let mut p = base.to_path_buf();
  p.push("logs");
  p.push(name);
  p.set_extension("txt");
  p
}

Creating directories and walking trees

The filesystem API can create nested directories and iterate directory entries. Always inspect each entry’s metadata because entries can be files, directories, or symlinks.

use std::fs;
use std::io;
use std::path::Path;

fn ensure_and_list(dir: &Path) -> io::Result<()> {
  fs::create_dir_all(dir)?;
  for entry in fs::read_dir(dir)? {
    let entry = entry?;
    let meta = entry.metadata()?;
    println!("{} (dir: {})",
             entry.path().display(),
             meta.is_dir());
  }
  Ok(())
}
TaskConvenienceStreaming
Read textfs::read_to_stringBufRead::read_line
Read bytesfs::readRead::read_exact
Write bytesfs::writeWrite::write_all
List entriesfs::read_dirwalkdir crate

Networking with std and tokio

Rust offers synchronous networking in std::net, which suits small utilities or simple servers. For high concurrency or nonblocking workloads, the tokio runtime exposes async I/O that scales to many connections with one thread pool and cooperative scheduling.

A tiny TCP server using std::net

This server accepts one connection at a time. It reads bytes and echoes them back. This pattern is great for exploring protocols and learning the I/O traits.

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};

fn handle(mut s: TcpStream) {
  let mut buf = [0u8; 1024];
  while let Ok(n) = s.read(&mut buf) {
    if n == 0 { break; }
    let _ = s.write_all(&buf[..n]);
  }
}

fn main() {
  let listener = TcpListener::bind("127.0.0.1:4000").expect("bind");
  for conn in listener.incoming() {
    handle(conn.expect("conn"));
  }
}
⚠️ Blocking I/O ties up the thread while waiting. For many concurrent clients, prefer async I/O to avoid creating a thread per connection.

The same idea with tokio

Async code uses async functions and await points. The runtime multiplexes tasks. Add a dependency and choose features in your manifest.

# Cargo.toml
[dependencies]
tokio = { version = "1", features = […] }
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

async fn handle(mut s: TcpStream) {
  let mut buf = [0u8; 1024];
  loop {
    let n = match s.read(&mut buf).await {
      Ok(0) -> break,
      Ok(n) -> n,
      Err(_) -> break,
    };
    if s.write_all(&buf[..n]).await.is_err() { break; }
  }
}

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
  let listener = TcpListener::bind("127.0.0.1:4000").await?;
  loop {
    let (sock, _) = listener.accept().await?;
    tokio::spawn(async move { handle(sock).await; });
  }
}

UDP and timeouts

std::net::UdpSocket and tokio::net::UdpSocket support datagrams. With TCP or UDP you can set timeouts to keep calls from hanging forever. In async code, use tokio::time::timeout; in blocking code, use set_read_timeout and set_write_timeout.

use std::net::UdpSocket;
use std::time::Duration;

fn main() {
  let s = UdpSocket::bind("0.0.0.0:0").unwrap();
  s.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
  s.send_to(b"hi", "127.0.0.1:4001").unwrap();
}

Command line parsing and env

Small utilities often read positional arguments, flags, and environment variables. For quick scripts, std::env is enough. For robust parsing and usage text, a dedicated crate such as clap keeps code declarative and user friendly.

Quick reads with std::env::args

args yields an iterator of program arguments. The first element is the executable path. Convert to numbers or paths as needed and validate input early for better error messages.

use std::env;

fn main() {
  let mut a = env::args();
  let _exe = a.next();
  let name = a.next().unwrap_or_else(|| "world".into());
  println!("Hello, {}", name);
}

Loading environment variables with std::env

Environment variables configure behavior without editing code. Use a default when a variable is missing. Treat secrets carefully and avoid printing them.

use std::env;

fn endpoint() -> String {
  env::var("API_ENDPOINT").unwrap_or_else(|_| "https://example.com".into())
}

Structured parsing with clap

clap derives a parser from a struct which provides automatic help and validation. This approach centralizes the interface and reduces boilerplate.

# Cargo.toml
[dependencies]
clap = { version = "4", features = […] }
use clap::Parser;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Opts {
  #[arg(short, long, default_value_t = 4000)]
  port: u16,
  #[arg(short = 'H', long)]
  host: Option<String>,
}

fn main() {
  let opts = Opts::parse();
  println!("Listening on {}:{}",
           opts.host.as_deref().unwrap_or("127.0.0.1"),
           opts.port);
}
💡 Keep the CLI stable. Add new options in backward compatible ways and reserve breaking changes for major releases.

Serialization with serde

serde provides fast, general purpose serialization for Rust. With a few traits you can serialize to formats such as JSON, TOML, and CBOR. The same data structures work with multiple formats which encourages clean domain models.

Deriving Serialize and Deserialize

Add the derive feature, then mark your types. Optional fields and enums map naturally across formats. Custom field names are also supported with attributes.

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Config {
  host: String,
  port: u16,
  #[serde(default)]
  debug: bool,
}

Reading and writing JSON

The serde_json crate reads and writes UTF-8 JSON. Use to_string_pretty when humans will read the output. For large data, use reader and writer streams.

use serde_json::{from_str, to_string_pretty};

fn roundtrip() {
  let s = r#"{"host":"localhost","port":4000}"#;
  let cfg: Config = from_str(s).unwrap();
  let out = to_string_pretty(&cfg).unwrap();
  println!("{}", out);
}

Swapping formats without changing types

Because serde is format agnostic, the same Config works with other crates. For TOML, use toml. For CBOR, use serde_cbor. This keeps I/O choices outside of core logic.

# Cargo.toml
[dependencies]
toml = "0.8"
fn parse_toml(txt: &str) -> Result<Config, toml::de::Error> {
  toml::from_str(txt)
}
⚠️ When persisting data, version your schema. Add new fields with defaults and avoid changing meanings of existing fields. Compatibility reduces friction for your users.

Time, random, and OS specifics

Programs measure durations, schedule retries, generate random values, and occasionally need platform specific behavior. The standard library covers durations and monotonic time. For randomness, the rand crate is the common choice. For platform details, conditional compilation keeps code portable while enabling targeted features where available.

Durations and monotonic clocks

Instant and Duration measure elapsed time without being affected by system clock changes. Use SystemTime only when you need a timestamp that matches the operating system clock.

use std::time::{Duration, Instant};

fn main() {
  let start = Instant::now();
  // work …
  let elapsed = start.elapsed();
  if elapsed > Duration::from_millis(10) {
    println!("Slow path: {:?}", elapsed);
  }
}

Random numbers with rand

rand provides secure generators and distributions. Use thread_rng for convenience or create a dedicated RNG if you need reproducibility with a seed.

# Cargo.toml
[dependencies]
rand = "0.8"
use rand::Rng;

fn main() {
  let mut rng = rand::thread_rng();
  let x: u32 = rng.gen_range(0..100);
  println!("Picked {}", x);
}
💡 For cryptography, use emphasis on audited crates designed for security, not plain PRNG output. Choose APIs that make misuse difficult.

Sleeping and scheduling

Sleeping yields control so other work can run. In async code you should use the runtime’s sleep which does not block the thread. In synchronous code you can sleep the current thread.

std::thread::sleep(std::time::Duration::from_millis(200));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;

Conditional compilation for OS behavior

Some APIs exist only on certain platforms. Use cfg attributes to select the correct code path while keeping a single codebase. Provide a fallback when a feature is not available.

#[cfg(unix)]
fn home_dir() -> Option<std::path::PathBuf> {
  std::env::var_os("HOME").map(Into::into)
}

#[cfg(windows)]
fn home_dir() -> Option<std::path::PathBuf> {
  std::env::var_os("USERPROFILE").map(Into::into)
}

With these tools you can build utilities that feel native on every target platform. Combine precise error handling with careful resource management and your programs will behave predictably even under load or failure.