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.