File I/O, serde and everyday crates

std::fs and buffered readers and writers, path handling, serde and serde_json, clap, anyhow versus thiserror, reqwest, and tracing.

Files, paths and buffering

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

fn main() -> io::Result<()> {
    // small file: one call, whole contents in memory
    let text = fs::read_to_string("notes.toml")?;

    // large or line-oriented: buffer, never read byte by byte from the file
    let file = File::open("events.log")?;
    let reader = BufReader::new(file);
    for line in reader.lines() {
        let line = line?;
        if line.contains("ERROR") {
            println!("{line}");
        }
    }

    // writing: buffer, then flush or let the drop close it
    let out = File::create("out.txt")?;
    let mut w = BufWriter::new(out);
    writeln!(w, "{text}")?;
    w.flush()?;

    // paths are not strings: join with PathBuf, never with format!
    let base: PathBuf = [std::env::temp_dir(), PathBuf::from("notes")].iter().collect();
    fs::create_dir_all(&base)?;
    let target: PathBuf = base.join("data").with_extension("json");
    assert_eq!(target.extension().and_then(|e| e.to_str()), Some("json"));

    for entry in fs::read_dir(&base)? {
        let entry = entry?;
        let p: &Path = &entry.path();
        println!("{} is_file={}", p.display(), p.is_file());
    }
    Ok(())
}
  • Unbuffered per-byte or per-line reads on a File are a syscall each. BufReader and BufWriter turn thousands of calls into a handful.
  • The ? operator converts an io::Error into your error type through From, so functions returning io::Result<T> stay short and readable.
  • Path and PathBuf are the borrow and owned pair, exactly like str and String. display() is for printing; the underlying OsStr may not be valid UTF-8.
  • Take &Path (or impl AsRef<Path>) as a parameter so callers can pass &str or PathBuf without conversion.
  • Walk directories with walkdir rather than recursion — it handles symlink loops, depth limits and error collection, all of which are easy to get wrong by hand.

serde and serde_json

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    name: String,
    port: u16,
    #[serde(default)]
    debug: bool,
    #[serde(rename = "maxConnections", default = "default_max")]
    max_connections: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
    #[serde(skip)]
    runtime_cache: Vec<u8>,
}

fn default_max() -> usize { 100 }

// an enum maps to a tagged union in JSON
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Event {
    Started { at: u64 },
    Stopped,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg: Config = serde_json::from_str(r#"{"name":"notes","port":8080}"#)?;
    println!("{cfg:?}");

    let json = serde_json::to_string_pretty(&cfg)?;
    println!("{json}");

    // streaming from a reader is better than reading a whole file first
    let file = std::fs::File::open("config.json")?;
    let parsed: Config = serde_json::from_reader(file)?;
    println!("{}", parsed.port);

    let events: Vec<Event> = serde_json::from_str(r#"[{"kind":"stopped"}]"#)?;
    println!("{events:?}");
    Ok(())
}
AttributeEffect
#[serde(default)]Fills a missing field with Default::default() instead of failing
#[serde(rename = "x")]Uses a different wire name, keeping the Rust field idiomatic
#[serde(deny_unknown_fields)]Rejects unexpected keys, turning a typo into a clear error
#[serde(flatten)]Inlines a nested struct's fields into the parent object
#[serde(tag = "kind")]Internal tagging: the variant name appears as a field
#[serde(skip)]Leaves the field out of both directions; needs a default when deserialising
  • serde_json::from_reader and to_writer stream; from_str and to_string build an owned value. Use the streaming pair for files and network payloads.
  • serde_json::Value is the escape hatch for genuinely dynamic data. Keep it at the boundary and convert into typed structs early; untyped JSON propagating through an application loses every guarantee Rust gives you.
  • Deserialisation borrows when possible: &'a str with #[serde(borrow)] avoids allocating for large payloads.
  • Serialise the same struct to TOML, YAML or MessagePack by adding the format crate. The derive attributes carry over, which is the reason to model data with serde types rather than ad-hoc maps.

The everyday crate set

use clap::Parser;
use tracing::{info, warn};

/// Manage local notes.
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// Path to the notes database
    #[arg(short, long, default_value = "notes.db")]
    path: String,

    /// Increase verbosity (-v, -vv)
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

    /// Subcommand to run
    #[command(subcommand)]
    command: Command,
}

#[derive(clap::Subcommand, Debug)]
enum Command {
    Add { text: String },
    List,
}

#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("notes file {0} not found")]
    NotFound(String),
    #[error("could not read {path}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error(transparent)]
    Parse(#[from] serde_json::Error),
}

fn run(args: &Args) -> Result<(), AppError> {
    // tracing: structured, levelled, with spans across async boundaries
    info!(path = %args.path, verbose = args.verbose, "starting");
    if args.verbose == 0 {
        warn!("default verbosity in use");
    }
    match args.command {
        Command::Add { ref text } => println!("adding {text}"),
        Command::List => println!("listing"),
    }
    Ok(())
}

fn main() {
    let args = Args::parse();
    if let Err(e) = run(&args) {
        eprintln!("error: {e}");
        // anyhow's context chain prints every cause with {:#}
        std::process::exit(1);
    }
}
  • clap with derive turns a struct into a full CLI: help text, version, short and long flags, subcommands and validation all come from the types and doc comments.
  • thiserror is for libraries: it generates a typed error enum with Display, Error::source and From conversions. Callers can match on the variants.
  • anyhow is for applications: anyhow::Result<T> with .context("reading config") produces a readable chain without defining an enum. It erases the type, which is acceptable when nothing above you matches on it.
  • reqwest is the standard HTTP client, with a blocking and an async API. Set timeouts explicitly — Client::builder().timeout(...) — because the default is no total timeout, and reuse one client for connection pooling.
  • tracing plus tracing-subscriber replaces print statements with levels, structured fields and spans that follow work across threads and tasks.
⚠️
Every dependency is code you now maintain and must audit. Check whether the standard library covers it first: std::fs, std::io, std::path, std::collections and std::sync handle most everyday work. A crate earns its place when it removes a class of bugs — serde and clap do; a two-function helper published last week usually does not.

FAQ

anyhow or thiserror?
thiserror in a library, so callers can match on error variants. anyhow in a binary, where a readable chain of context beats an exhaustive enum. Many projects use both: thiserror for the core types, anyhow at the top level.
Why is serde_json slower than I expected?
Usually because the payload passes through Value or is read with from_str on a large string. Deserialise straight into a typed struct with from_reader, and avoid intermediate Value conversions in hot paths.

Collections, strings and iterators A command-line tool end to end

Last refreshed 2026-09-18.