DaleSchool

File I/O and JSON Serialization

Intermediate15min

Learning Objectives

  • Read and write files with std::fs
  • Serialize and deserialize structs with serde/serde_json
  • Handle errors in file I/O with Result and ?

Working Code

In Phase 4 projects you'll often save settings, data, and logs to files. Combining std::fs with serde makes JSON-based config files easy to work with.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

const CONFIG_PATH: &str = "config.json";

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

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".into(),
            port: 8080,
            debug: false,
        }
    }
}

fn load_config<P: AsRef<Path>>(path: P) -> Result<AppConfig, Box<dyn std::error::Error>> {
    if !path.as_ref().exists() {
        let default = AppConfig::default();
        save_config(&default, &path)?;
        return Ok(default);
    }

    let raw = fs::read_to_string(&path)?;
    let config = serde_json::from_str(&raw)?;
    Ok(config)
}

fn save_config<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<(), Box<dyn std::error::Error>> {
    let json = serde_json::to_string_pretty(config)?;
    fs::write(path, json)?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut config = load_config(CONFIG_PATH)?;
    config.debug = true;
    save_config(&config, CONFIG_PATH)?;
    println!("Current config: {:?}", config);
    Ok(())
}
  • serde_json::to_string_pretty produces human-readable JSON.
  • Box<dyn Error> lets you return any error type from a single function.
  • The "create default config if file doesn't exist" pattern is common in CLI tools.

A Minimal read_to_string / write Example

use std::fs;

fn main() -> std::io::Result<()> {
    fs::write("hello.txt", "Hello!\nThis is Rust.")?;
    let contents = fs::read_to_string("hello.txt")?;
    println!("File contents:\n{contents}");
    Ok(())
}

Thanks to ?, error handling stays clean. On failure, Err propagates to the caller; on success, execution continues.

Try It Yourself

  1. Add a log_path: Option<String> field to AppConfig and use the serde(default) attribute so the field falls back to a default when missing from the JSON.
  2. Modify load_config to log a friendly message and return a default when JSON parsing fails.
  3. Try using the serde_yaml crate to also support YAML format for saving and loading.
  1. History store — Define struct History(Vec<String>). Write functions to save it as JSON on exit and restore it on startup.
  2. Pretty vs compact — Compare file sizes when saving with to_string (compact) vs to_string_pretty. Summarize when to use each.
  3. Custom error type — Create enum ConfigError and implement From<std::io::Error> / From<serde_json::Error>. Refactor load/save to return Result<T, ConfigError>.

Q1. Which traits must you derive to save AppConfig as JSON?

  • A) Copy
  • B) Serialize / Deserialize
  • C) Debug / Display
  • D) Default

Q2. What does fs::read_to_string return?

  • A) String
  • B) Result<String, std::io::Error>
  • C) Option<String>
  • D) &str

Q3. What characterizes serde_json::to_string_pretty?

  • A) It serializes to binary format
  • B) It generates human-readable JSON with indentation
  • C) It requires UTF-16
  • D) It never returns an error