Day 25/90: The AppConfig Loader That Panicked on a Trailing Comma
90 Day Rust Challenge: Zero to Production
The first time a user shipped my tool a config file with a stray trailing comma, the whole binary went down. Not a message, not a fallback — a panic and a backtrace. The cause was a line I wrote without thinking, because it was a demo and demos always have valid input:
fn read_config(p: &str) -> AppConfig {
let bytes = std::fs::read_to_string(p).unwrap();
let parsed: AppConfig = serde_json::from_str(&bytes).unwrap();
parsed
}
Two .unwrap() calls, two ways to crash. The first panics on a missing or unreadable file. The second panics on JSON that is invalid or does not match my struct.
Why these calls return Result
std::fs::read_to_string returns io::Result<String>, which is Result<String, std::io::Error>. serde_json::from_str returns a Result too. Both signatures are telling you, in the type, that the operation can fail — because reading a file you did not write, and parsing text you did not generate, can genuinely fail. Calling .unwrap() throws away the Err half and turns a normal error into a panic.
The fix is the ? operator. It returns the error to the caller instead of crashing:
use std::fs;
fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
let raw = fs::read_to_string(path)?;
let config: AppConfig = serde_json::from_str(&raw)?;
Ok(config)
}
In a real loader, those ? conversions would land in the thiserror enum from yesterday's post, so each error becomes a named variant instead of a stringly-typed blob.
The AppConfig struct and two serde attributes
You describe the shape once with two derives and serde generates the read and write code:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AppConfig {
name: String,
host: String,
port: u16,
#[serde(default)]
verbose: bool,
#[serde(default)]
retries: u32,
#[serde(default)]
max_conns: u32,
}
#[serde(default)] makes verbose optional. A config that omits it deserializes fine and the field becomes false. Without that attribute, a missing field is a hard missing field error. That one line is how you declare "optional" right on the field.
Round-trip: read, mutate, write
The full loop — read, deserialize, mutate, serialize with to_string_pretty, write — with ? on every fallible step:
use std::fs;
use std::error::Error;
fn bump_port(path: &str) -> Result<(), Box<dyn Error>> {
let text = fs::read_to_string(path)?;
let mut config: AppConfig = serde_json::from_str(&text)?;
config.port += 1;
let out = serde_json::to_string_pretty(&config)?;
fs::write(path, out)?;
Ok(())
}
fn main() {
if let Err(e) = bump_port("config.json") {
eprintln!("could not process config: {e}");
std::process::exit(1);
}
}
to_string_pretty writes the file with indentation so it diffs cleanly. Feed this a malformed file and you get could not process config: expected ... on stderr plus exit code 1, instead of a backtrace.
Streaming large files
from_str loads the entire file into a String, then parses it — fine for a config, wasteful for a big data file. from_reader over a BufReader parses straight from the stream:
use std::fs::File;
use std::io::BufReader;
fn load_big(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let reader = BufReader::with_capacity(64 * 1024, file);
let config: AppConfig = serde_json::from_reader(reader)?;
Ok(config)
}
Use from_str when you already hold a string; use from_reader over a BufReader when you are reading off disk and the file might be large.
The rule I kept
.unwrap() on fs and serde_json calls is not error handling. It is a deferred panic that fires on the first imperfect input — and there is always a first imperfect input. Reading and parsing both return Result. The ? operator turns "the program died" into "the function returned an error the caller can read," and it costs one character.
Top comments (0)