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
Fileare a syscall each.BufReaderandBufWriterturn thousands of calls into a handful. - The
?operator converts anio::Errorinto your error type throughFrom, so functions returningio::Result<T>stay short and readable. PathandPathBufare the borrow and owned pair, exactly likestrandString.display()is for printing; the underlyingOsStrmay not be valid UTF-8.- Take
&Path(orimpl AsRef<Path>) as a parameter so callers can pass&strorPathBufwithout conversion. - Walk directories with
walkdirrather 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(())
}| Attribute | Effect |
|---|---|
#[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_readerandto_writerstream;from_strandto_stringbuild an owned value. Use the streaming pair for files and network payloads.serde_json::Valueis 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 strwith#[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);
}
}clapwithderiveturns a struct into a full CLI: help text, version, short and long flags, subcommands and validation all come from the types and doc comments.thiserroris for libraries: it generates a typed error enum withDisplay,Error::sourceandFromconversions. Callers can match on the variants.anyhowis 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.reqwestis 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.tracingplustracing-subscriberreplaces 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.Related
Collections, strings and iterators A command-line tool end to end
Last refreshed 2026-09-18.